feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)

Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:58:30 +02:00
co-authored by Claude Opus 5
parent 19b0751c16
commit d416f3869a
38 changed files with 1014 additions and 60 deletions
@@ -1,23 +1,32 @@
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "System" };
export default function SystemSeite() {
export default async function SystemSeite() {
const werte = await einstellungen();
return (
<>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary">
<SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
<Card titel="Systeminformationen" rahmen="keiner">
<dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
<dd className="col-8">Konfigurierbar ab #011</dd>
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
<dd className="col-8">
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
die Datenbank gelesen werden kann.
</dd>
</dl>
</Card>
</div>
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
import { einstellungen } from "@/server/einstellungen";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Host-Liste",
untertitel: `${alle.length} Hosts`,
quer: true,
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10);
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [
@@ -1,6 +1,7 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { einstellungen } from "@/server/einstellungen";
import {
kundenListe,
offeneRechnungspunkteProKunde,
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`,
quer: true,
+11 -4
View File
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
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";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +29,12 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
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 verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
nettoBetrag: verkaufVorschlag,
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true,
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request));
const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh");
}
+12 -7
View File
@@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers";
import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -62,13 +63,17 @@ export async function POST(request: Request) {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
},
sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303);
} catch (fehler) {
+12 -7
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -25,13 +26,17 @@ export async function GET(request: Request) {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
});
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
const { sitzungsdauerStunden } = await systemEinstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
},
sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
} catch (fehler) {
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request));
const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh");
}