feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)
Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen, Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und globale Suche laufen jetzt auf der Datenbank. 45 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
|
||||
try {
|
||||
const { anzahl, kundenName } = await hostsZuweisenService(hostIds, kundeId, standortId);
|
||||
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kundeId}`);
|
||||
revalidatePath("/");
|
||||
|
||||
return { erfolg: `${anzahl} Host${anzahl === 1 ? "" : "s"} ${kundenName} zugewiesen.` };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ZuweisungsFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||
export async function hostZuordnungAufheben(formular: FormData) {
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId: null, standortId: null } });
|
||||
await protokolliere("host.zuordnung_aufgehoben", "Host", hostId, {});
|
||||
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath(`/hosts/${hostId}`);
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
felder?: Record<string, string>;
|
||||
erfolg?: string;
|
||||
}
|
||||
|
||||
const leerZuNull = (wert: FormDataEntryValue | null) => {
|
||||
const text = typeof wert === "string" ? wert.trim() : "";
|
||||
return text.length > 0 ? text : null;
|
||||
};
|
||||
|
||||
const kundeSchema = z.object({
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
kundennummer: z.string().min(1, "Bitte eine Kundennummer angeben."),
|
||||
ansprechpartner: z.string().nullable(),
|
||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||
telefon: z.string().nullable(),
|
||||
notizen: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = kundeSchema.safeParse({
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
kundennummer: formular.get("kundennummer")?.toString().trim() ?? "",
|
||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||
email: leerZuNull(formular.get("email")),
|
||||
telefon: leerZuNull(formular.get("telefon")),
|
||||
notizen: leerZuNull(formular.get("notizen")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const vorhandeneNummer = await prisma.kunde.findUnique({
|
||||
where: { kundennummer: eingabe.data.kundennummer },
|
||||
});
|
||||
if (vorhandeneNummer && vorhandeneNummer.id !== id) {
|
||||
return {
|
||||
fehler: "Diese Kundennummer ist bereits vergeben.",
|
||||
felder: { kundennummer: "Bereits vergeben." },
|
||||
};
|
||||
}
|
||||
|
||||
const kunde = id
|
||||
? await prisma.kunde.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.kunde.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "kunde.geaendert" : "kunde.angelegt", "Kunde", kunde.id, eingabe.data);
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kunde.id}`);
|
||||
redirect(`/kunden/${kunde.id}`);
|
||||
}
|
||||
|
||||
export async function kundeLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
const kunde = await prisma.kunde.findUnique({ where: { id }, select: { name: true } });
|
||||
// Hosts bleiben erhalten und gelten danach als nicht zugeordnet (onDelete: SetNull).
|
||||
await prisma.kunde.delete({ where: { id } });
|
||||
await protokolliere("kunde.geloescht", "Kunde", id, { name: kunde?.name });
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
redirect("/kunden");
|
||||
}
|
||||
|
||||
const standortSchema = z.object({
|
||||
kundeId: z.string().min(1),
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
strasse: z.string().nullable(),
|
||||
plz: z.string().nullable(),
|
||||
ort: z.string().nullable(),
|
||||
});
|
||||
|
||||
export async function standortSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = standortSchema.safeParse({
|
||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
strasse: leerZuNull(formular.get("strasse")),
|
||||
plz: leerZuNull(formular.get("plz")),
|
||||
ort: leerZuNull(formular.get("ort")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const standort = id
|
||||
? await prisma.standort.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.standort.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "standort.geaendert" : "standort.angelegt", "Standort", standort.id, eingabe.data);
|
||||
revalidatePath(`/kunden/${eingabe.data.kundeId}`);
|
||||
return { erfolg: "Standort gespeichert." };
|
||||
}
|
||||
|
||||
export async function standortLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.standort.delete({ where: { id } });
|
||||
await protokolliere("standort.geloescht", "Standort", id, {});
|
||||
if (kundeId) revalidatePath(`/kunden/${kundeId}`);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { erzeugeToken } from "@/lib/tokens";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
export interface TokenErgebnis extends AktionsErgebnis {
|
||||
/** Nur direkt nach dem Erzeugen gesetzt – danach ist der Token nicht mehr lesbar. */
|
||||
klartext?: string;
|
||||
}
|
||||
|
||||
/** Erzeugt einen Enrollment-Token; der Klartext wird genau einmal zurückgegeben. */
|
||||
export async function enrollmentTokenErzeugen(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
const gueltigTage = Number(formular.get("gueltigTage") ?? 30);
|
||||
const maxNutzungenRoh = formular.get("maxNutzungen")?.toString().trim();
|
||||
const maxNutzungen = maxNutzungenRoh ? Number(maxNutzungenRoh) : null;
|
||||
|
||||
if (!Number.isFinite(gueltigTage) || gueltigTage < 1 || gueltigTage > 365) {
|
||||
return { fehler: "Gültigkeit muss zwischen 1 und 365 Tagen liegen." };
|
||||
}
|
||||
if (maxNutzungen !== null && (!Number.isInteger(maxNutzungen) || maxNutzungen < 1)) {
|
||||
return { fehler: "Die Anzahl der Nutzungen muss mindestens 1 sein." };
|
||||
}
|
||||
|
||||
const token = erzeugeToken("enrollment");
|
||||
const eintrag = await prisma.enrollmentToken.create({
|
||||
data: {
|
||||
tokenHash: token.hash,
|
||||
praefix: token.praefix,
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
standortId,
|
||||
maxNutzungen,
|
||||
gueltigBis: new Date(Date.now() + gueltigTage * 86_400_000),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere("enrollment_token.angelegt", "EnrollmentToken", eintrag.id, {
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
gueltigTage,
|
||||
maxNutzungen,
|
||||
});
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
return {
|
||||
klartext: token.klartext,
|
||||
erfolg: "Token erzeugt. Er wird nur dieses eine Mal angezeigt.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.enrollmentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("enrollment_token.widerrufen", "EnrollmentToken", id, {});
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
}
|
||||
|
||||
export async function hostTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.agentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("agent_token.widerrufen", "AgentToken", id, { hostId });
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
if (hostId) revalidatePath(`/hosts/${hostId}`);
|
||||
}
|
||||
@@ -20,11 +20,7 @@ export function fehlerBehandeln(fehler: unknown) {
|
||||
}
|
||||
|
||||
/** Prüft das Rate-Limit für IP und (falls vorhanden) Token. */
|
||||
export function rateLimitPruefen(
|
||||
limit: RateLimit,
|
||||
headers: Headers,
|
||||
token: string | null,
|
||||
): Response | null {
|
||||
export function rateLimitPruefen(limit: RateLimit, headers: Headers, token: string | null): Response | null {
|
||||
const schluessel = token ? `token:${token.slice(0, 16)}` : `ip:${clientIp(headers)}`;
|
||||
const ergebnis = limit.pruefe(schluessel);
|
||||
if (ergebnis.erlaubt) return null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Schreibt einen Eintrag ins Audit-Log. Solange es keine Anmeldung gibt (#013),
|
||||
* bleibt `benutzerId` leer und die Änderung gilt als vom System ausgelöst.
|
||||
*/
|
||||
export async function protokolliere(
|
||||
aktion: string,
|
||||
entitaet: string,
|
||||
entitaetId: string | null,
|
||||
aenderung?: Record<string, unknown>,
|
||||
) {
|
||||
await prisma.auditEintrag.create({
|
||||
data: {
|
||||
aktion,
|
||||
entitaet,
|
||||
entitaetId,
|
||||
aenderung: aenderung ? JSON.parse(JSON.stringify(aenderung)) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { meldungsGrenze } from "./hosts";
|
||||
|
||||
/**
|
||||
* Hosts nach Ablaufdatum mit der Laufzeit, die zu diesem Ablauf gehört.
|
||||
* Gezeigt wird bewusst die Verlängerung (die Laufzeit, die am `nextduedate` beginnt),
|
||||
* denn deren Rechnungen sind offen – nicht die der laufenden Periode.
|
||||
*/
|
||||
export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null } },
|
||||
orderBy: { subNextduedate: "asc" },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
laufzeiten: {
|
||||
where: { status: "BESTAETIGT" },
|
||||
orderBy: { start: "desc" },
|
||||
include: {
|
||||
proxmoxPosition: { include: { rechnung: { select: { nummer: true, bezahlt: true } } } },
|
||||
kundenPosition: { include: { rechnung: { select: { erstellt: true, bezahlt: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const grenze = meldungsGrenze(jetzt);
|
||||
|
||||
return hosts.map((host) => {
|
||||
const ablauf = host.subNextduedate;
|
||||
const verlaengerung = host.laufzeiten.find(
|
||||
(laufzeit) => ablauf && laufzeit.start.getTime() === ablauf.getTime(),
|
||||
);
|
||||
const laufend = host.laufzeiten[0];
|
||||
const laufzeit = verlaengerung ?? laufend;
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
kunde: host.kunde,
|
||||
subKey: host.subKey,
|
||||
nextduedate: ablauf,
|
||||
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze,
|
||||
einkaufBezahlt: laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
verkaufErstellt: laufzeit?.kundenPosition?.rechnung.erstellt ?? false,
|
||||
verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */
|
||||
export async function offeneAufgaben() {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
OR: [
|
||||
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
{ kundenPosition: null },
|
||||
{ kundenPosition: { rechnung: { erstellt: false } } },
|
||||
],
|
||||
},
|
||||
orderBy: { start: "asc" },
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const aufgaben = [];
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`;
|
||||
const einkauf = laufzeit.proxmoxPosition?.rechnung;
|
||||
|
||||
if (einkauf && !einkauf.bezahlt) {
|
||||
aufgaben.push({
|
||||
id: `${laufzeit.id}-einkauf`,
|
||||
titel: `Proxmox-Rechnung ${einkauf.nummer} bezahlen`,
|
||||
detail: basis,
|
||||
betrag: Number(laufzeit.proxmoxPosition?.nettoBetrag ?? 0),
|
||||
faelligAm: einkauf.faelligAm,
|
||||
dringlichkeit: "gefahr" as const,
|
||||
badgeText: "Einkauf",
|
||||
href: `/hosts/${laufzeit.hostId}`,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
});
|
||||
}
|
||||
|
||||
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
||||
aufgaben.push({
|
||||
id: `${laufzeit.id}-verkauf`,
|
||||
titel: "Kundenrechnung erstellen",
|
||||
detail: basis,
|
||||
betrag: null,
|
||||
faelligAm: null,
|
||||
dringlichkeit: "warnung" as const,
|
||||
badgeText: "Verkauf",
|
||||
href: "/rechnungen",
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return aufgaben;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */
|
||||
export const MELDUNG_FRIST_STUNDEN = 48;
|
||||
|
||||
export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
||||
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
|
||||
}
|
||||
|
||||
export async function hostsOhneKunde() {
|
||||
return prisma.host.findMany({
|
||||
where: { kundeId: null },
|
||||
orderBy: { erstmalsGesehenAm: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function hostAnzahl() {
|
||||
return prisma.host.count();
|
||||
}
|
||||
|
||||
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
|
||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||
prisma.host.count(),
|
||||
prisma.host.count({ where: { subStatus: "ACTIVE" } }),
|
||||
prisma.host.count({ where: { kundeId: null } }),
|
||||
prisma.host.count({ where: { subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||
prisma.host.count({
|
||||
where: { OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }] },
|
||||
}),
|
||||
prisma.alert.count({ where: { erledigtAm: null } }),
|
||||
]);
|
||||
|
||||
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
||||
prisma.laufzeit.count({
|
||||
where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
}),
|
||||
prisma.laufzeit.count({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
hostsGesamt: gesamt,
|
||||
aktivLizenziert: aktiv,
|
||||
ohneSubscription: gesamt - aktiv,
|
||||
ohneKunde,
|
||||
baldAblaufend,
|
||||
ohneMeldung,
|
||||
offeneAlerts,
|
||||
offeneEinkaufsrechnungen,
|
||||
fehlendeKundenrechnungen,
|
||||
offeneRechnungspunkte: offeneEinkaufsrechnungen + fehlendeKundenrechnungen,
|
||||
};
|
||||
}
|
||||
|
||||
export async function offeneAlerts(anzahl = 20) {
|
||||
return prisma.alert.findMany({
|
||||
where: { erledigtAm: null },
|
||||
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
|
||||
take: anzahl,
|
||||
});
|
||||
}
|
||||
|
||||
/** Einträge für die globale Suche (Strg+K). */
|
||||
export async function suchIndex() {
|
||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
fqdn: true,
|
||||
serverId: true,
|
||||
subKey: true,
|
||||
ips: true,
|
||||
produkt: true,
|
||||
kunde: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.kunde.findMany({
|
||||
select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true },
|
||||
}),
|
||||
prisma.proxmoxRechnung.findMany({
|
||||
select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
|
||||
}),
|
||||
prisma.kundenrechnung.findMany({
|
||||
where: { nummer: { not: null } },
|
||||
select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const eintraege = [
|
||||
...hosts.map((host) => ({
|
||||
typ: "Host" as const,
|
||||
titel: host.hostname,
|
||||
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
||||
host.subKey ? ` · ${host.subKey}` : ""
|
||||
}`,
|
||||
href: `/hosts/${host.id}`,
|
||||
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips]
|
||||
.filter((w): w is string => Boolean(w))
|
||||
.map((w) => w.toLowerCase()),
|
||||
})),
|
||||
...kunden.map((kunde) => ({
|
||||
typ: "Kunde" as const,
|
||||
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()),
|
||||
})),
|
||||
...proxmoxRechnungen.map((rechnung) => ({
|
||||
typ: "Rechnung" as const,
|
||||
titel: rechnung.nummer,
|
||||
untertitel: `Proxmox-Rechnung · ${rechnung.positionen.length} Position(en)`,
|
||||
href: `/rechnungen?q=${encodeURIComponent(rechnung.nummer)}`,
|
||||
begriffe: [rechnung.nummer.toLowerCase()],
|
||||
})),
|
||||
...kundenrechnungen.map((rechnung) => ({
|
||||
typ: "Rechnung" as const,
|
||||
titel: rechnung.nummer ?? "",
|
||||
untertitel: `Kundenrechnung · ${rechnung.kunde.name}`,
|
||||
href: `/kunden/${rechnung.kunde.id}`,
|
||||
begriffe: [(rechnung.nummer ?? "").toLowerCase()],
|
||||
})),
|
||||
];
|
||||
|
||||
return eintraege;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
|
||||
|
||||
export async function kundenListe() {
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
standorte: true,
|
||||
hosts: {
|
||||
select: { id: true, subStatus: true, subNextduedate: true },
|
||||
},
|
||||
_count: { select: { hosts: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const offeneAlerts = await prisma.alert.groupBy({
|
||||
by: ["kundeId"],
|
||||
where: { erledigtAm: null, kundeId: { not: null } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const alertsProKunde = new Map(offeneAlerts.map((e) => [e.kundeId, e._count._all]));
|
||||
|
||||
return kunden.map((kunde) => {
|
||||
const ablaufDaten = kunde.hosts
|
||||
.map((h) => h.subNextduedate)
|
||||
.filter((d): d is Date => d !== null)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
return {
|
||||
id: kunde.id,
|
||||
name: kunde.name,
|
||||
kundennummer: kunde.kundennummer,
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
hosts: kunde._count.hosts,
|
||||
aktiv: kunde.hosts.filter((h) => h.subStatus === "ACTIVE").length,
|
||||
naechsterAblauf: ablaufDaten[0] ?? null,
|
||||
offeneAlerts: alertsProKunde.get(kunde.id) ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function kundeMitDetails(id: string) {
|
||||
return prisma.kunde.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
standorte: { orderBy: { name: "asc" } },
|
||||
hosts: { orderBy: { hostname: "asc" } },
|
||||
alerts: { where: { erledigtAm: null }, orderBy: { geoeffnetAm: "desc" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
|
||||
export async function laufzeitenVonKunde(kundeId: string) {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: { host: { kundeId } },
|
||||
orderBy: { start: "desc" },
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return laufzeiten.map((laufzeit) => ({
|
||||
id: laufzeit.id,
|
||||
hostId: laufzeit.hostId,
|
||||
hostname: laufzeit.host.hostname,
|
||||
key: laufzeit.key,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
vorschlag: laufzeit.status === "VORSCHLAG",
|
||||
einkauf: laufzeit.proxmoxPosition
|
||||
? {
|
||||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
||||
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
||||
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
||||
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
||||
}
|
||||
: null,
|
||||
verkauf: laufzeit.kundenPosition
|
||||
? {
|
||||
nummer: laufzeit.kundenPosition.rechnung.nummer,
|
||||
nettoBetrag: Number(laufzeit.kundenPosition.nettoBetrag),
|
||||
erstellt: laufzeit.kundenPosition.rechnung.erstellt,
|
||||
bezahlt: laufzeit.kundenPosition.rechnung.bezahlt,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export type KundenLaufzeit = Awaited<ReturnType<typeof laufzeitenVonKunde>>[number];
|
||||
|
||||
export function summen(laufzeiten: KundenLaufzeit[]) {
|
||||
const einkauf = laufzeiten.reduce((s, l) => s + (l.einkauf?.nettoBetrag ?? 0), 0);
|
||||
const verkauf = laufzeiten.reduce((s, l) => s + (l.verkauf?.nettoBetrag ?? 0), 0);
|
||||
return { einkauf, verkauf, marge: verkauf - einkauf };
|
||||
}
|
||||
|
||||
export function summenProJahr(laufzeiten: KundenLaufzeit[]) {
|
||||
const proJahr = new Map<string, { einkauf: number; verkauf: number }>();
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const jahr = String(laufzeit.start.getUTCFullYear());
|
||||
const eintrag = proJahr.get(jahr) ?? { einkauf: 0, verkauf: 0 };
|
||||
eintrag.einkauf += laufzeit.einkauf?.nettoBetrag ?? 0;
|
||||
eintrag.verkauf += laufzeit.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 }));
|
||||
}
|
||||
|
||||
/** Offene Rechnungspunkte je Kunde: unbezahlter Einkauf bzw. fehlende Kundenrechnung. */
|
||||
export async function offeneRechnungspunkteProKunde() {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT", host: { kundeId: { not: null } } },
|
||||
select: {
|
||||
host: { select: { kundeId: true } },
|
||||
proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } },
|
||||
kundenPosition: { select: { rechnung: { select: { erstellt: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const proKunde = new Map<string, { offen: number; gesamt: number }>();
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const kundeId = laufzeit.host.kundeId;
|
||||
if (!kundeId) continue;
|
||||
const eintrag = proKunde.get(kundeId) ?? { offen: 0, gesamt: 0 };
|
||||
eintrag.gesamt += 2;
|
||||
if (!laufzeit.proxmoxPosition?.rechnung.bezahlt) eintrag.offen += 1;
|
||||
if (!laufzeit.kundenPosition?.rechnung.erstellt) eintrag.offen += 1;
|
||||
proKunde.set(kundeId, eintrag);
|
||||
}
|
||||
return proKunde;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
|
||||
export class ZuweisungsFehler extends Error {}
|
||||
|
||||
export interface ZuweisungsErgebnis {
|
||||
anzahl: number;
|
||||
kundenName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weist mehrere Hosts einem Kunden (und optional einem seiner Standorte) zu.
|
||||
* Getrennt von der Server Action, damit die Regeln ohne Next.js testbar sind.
|
||||
*/
|
||||
export async function hostsZuweisenService(
|
||||
hostIds: string[],
|
||||
kundeId: string | null,
|
||||
standortId: string | null,
|
||||
): Promise<ZuweisungsErgebnis> {
|
||||
if (hostIds.length === 0) {
|
||||
throw new ZuweisungsFehler("Bitte mindestens einen Host auswählen.");
|
||||
}
|
||||
if (!kundeId) {
|
||||
throw new ZuweisungsFehler("Bitte einen Kunden auswählen.");
|
||||
}
|
||||
|
||||
const kunde = await prisma.kunde.findUnique({
|
||||
where: { id: kundeId },
|
||||
include: { standorte: { select: { id: true } } },
|
||||
});
|
||||
if (!kunde) {
|
||||
throw new ZuweisungsFehler("Der gewählte Kunde existiert nicht mehr.");
|
||||
}
|
||||
if (standortId && !kunde.standorte.some((standort) => standort.id === standortId)) {
|
||||
throw new ZuweisungsFehler("Der gewählte Standort gehört nicht zu diesem Kunden.");
|
||||
}
|
||||
|
||||
const { count } = await prisma.host.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: { kundeId, standortId },
|
||||
});
|
||||
|
||||
// Die Warnung „Host ohne Kunde“ ist damit erledigt (die Regel selbst folgt in #011).
|
||||
await prisma.alert.updateMany({
|
||||
where: { regel: "host-ohne-kunde", hostId: { in: hostIds }, erledigtAm: null },
|
||||
data: { erledigtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliere("host.zugewiesen", "Host", null, { hostIds, kundeId, standortId, anzahl: count });
|
||||
|
||||
return { anzahl: count, kundenName: kunde.name };
|
||||
}
|
||||
Reference in New Issue
Block a user