512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { ExternalLink, MailCheck, ReceiptText, Save, Send, ShieldCheck } from "lucide-react";
|
|
|
|
import { useToast } from "@/components/common/ToastProvider";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { api } from "@/lib/api";
|
|
import type {
|
|
LexwareSettings,
|
|
LexwareSettingsPayload,
|
|
LexwareTestConnectionResponse,
|
|
} from "@/types/lexware";
|
|
import type {
|
|
PublicLinksSettings,
|
|
SettingsSource,
|
|
SmtpSettings,
|
|
SmtpSettingsPayload,
|
|
SmtpTestResponse,
|
|
} from "@/types/system-settings";
|
|
|
|
type SettingsTab = "smtp" | "public-links" | "lexware";
|
|
|
|
const sourceLabels: Record<SettingsSource, string> = {
|
|
database: "Admin-Konfiguration",
|
|
environment: "Umgebung",
|
|
missing: "Nicht konfiguriert",
|
|
};
|
|
|
|
function getErrorMessage(error: unknown, fallback: string) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
|
return response?.data?.detail ?? response?.data?.message ?? fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function emptySmtpSettings(): SmtpSettings {
|
|
return {
|
|
host: "",
|
|
port: 587,
|
|
username: "",
|
|
password_is_set: false,
|
|
from_email: "",
|
|
from_name: "Funktechnik Schubert",
|
|
use_tls: true,
|
|
enabled: false,
|
|
source: "missing",
|
|
};
|
|
}
|
|
|
|
function emptyLexwareSettings(): LexwareSettings {
|
|
return {
|
|
enabled: false,
|
|
api_base_url: "https://api.lexware.io",
|
|
api_key_is_set: false,
|
|
organization_name: "",
|
|
default_tax_rate: "19.00",
|
|
default_payment_terms_days: 14,
|
|
source: "missing",
|
|
};
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const { showToast } = useToast();
|
|
const [activeTab, setActiveTab] = useState<SettingsTab>("smtp");
|
|
const [smtp, setSmtp] = useState<SmtpSettings>(emptySmtpSettings());
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [testRecipient, setTestRecipient] = useState("");
|
|
const [publicLinks, setPublicLinks] = useState<PublicLinksSettings>({
|
|
repair_status_base_url: "",
|
|
source: "missing",
|
|
});
|
|
const [lexware, setLexware] = useState<LexwareSettings>(emptyLexwareSettings());
|
|
const [newLexwareApiKey, setNewLexwareApiKey] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [savingSmtp, setSavingSmtp] = useState(false);
|
|
const [sendingTest, setSendingTest] = useState(false);
|
|
const [savingPublicLinks, setSavingPublicLinks] = useState(false);
|
|
const [savingLexware, setSavingLexware] = useState(false);
|
|
const [testingLexware, setTestingLexware] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
setError("");
|
|
setLoading(true);
|
|
try {
|
|
const [smtpResponse, publicLinksResponse, lexwareResponse] = await Promise.all([
|
|
api.get<SmtpSettings>("/system-settings/smtp"),
|
|
api.get<PublicLinksSettings>("/system-settings/public-links"),
|
|
api.get<LexwareSettings>("/lexware/settings"),
|
|
]);
|
|
setSmtp(smtpResponse.data);
|
|
setPublicLinks(publicLinksResponse.data);
|
|
setLexware(lexwareResponse.data);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadSettings();
|
|
});
|
|
}, [loadSettings]);
|
|
|
|
async function saveSmtp() {
|
|
setSavingSmtp(true);
|
|
try {
|
|
const payload: SmtpSettingsPayload = {
|
|
host: smtp.host,
|
|
port: smtp.port,
|
|
username: smtp.username,
|
|
from_email: smtp.from_email,
|
|
from_name: smtp.from_name,
|
|
use_tls: smtp.use_tls,
|
|
enabled: smtp.enabled,
|
|
};
|
|
|
|
if (newPassword) {
|
|
payload.password = newPassword;
|
|
}
|
|
|
|
const response = await api.put<SmtpSettings>("/system-settings/smtp", payload);
|
|
setSmtp(response.data);
|
|
setNewPassword("");
|
|
showToast({ type: "success", title: "SMTP-Konfiguration gespeichert" });
|
|
} catch (err) {
|
|
showToast({
|
|
type: "error",
|
|
title: "SMTP konnte nicht gespeichert werden",
|
|
description: getErrorMessage(err, "Bitte prüfe die Eingaben."),
|
|
});
|
|
} finally {
|
|
setSavingSmtp(false);
|
|
}
|
|
}
|
|
|
|
async function sendTestMail() {
|
|
setSendingTest(true);
|
|
try {
|
|
const response = await api.post<SmtpTestResponse>("/system-settings/smtp/test", {
|
|
recipient: testRecipient,
|
|
});
|
|
showToast({
|
|
type: response.data.success ? "success" : "error",
|
|
title: response.data.success ? "Testmail versendet" : "Testmail fehlgeschlagen",
|
|
description: response.data.message,
|
|
});
|
|
} catch (err) {
|
|
showToast({
|
|
type: "error",
|
|
title: "Testmail fehlgeschlagen",
|
|
description: getErrorMessage(err, "Die Testmail konnte nicht versendet werden."),
|
|
});
|
|
} finally {
|
|
setSendingTest(false);
|
|
}
|
|
}
|
|
|
|
async function savePublicLinks() {
|
|
setSavingPublicLinks(true);
|
|
try {
|
|
const response = await api.put<PublicLinksSettings>("/system-settings/public-links", {
|
|
repair_status_base_url: publicLinks.repair_status_base_url,
|
|
});
|
|
setPublicLinks(response.data);
|
|
showToast({ type: "success", title: "Öffentliche Links gespeichert" });
|
|
} catch (err) {
|
|
showToast({
|
|
type: "error",
|
|
title: "Öffentliche Links konnten nicht gespeichert werden",
|
|
description: getErrorMessage(err, "Bitte prüfe die Basis-URL."),
|
|
});
|
|
} finally {
|
|
setSavingPublicLinks(false);
|
|
}
|
|
}
|
|
|
|
async function saveLexware() {
|
|
setSavingLexware(true);
|
|
try {
|
|
const payload: LexwareSettingsPayload = {
|
|
enabled: lexware.enabled,
|
|
api_base_url: lexware.api_base_url,
|
|
organization_name: lexware.organization_name,
|
|
default_tax_rate: lexware.default_tax_rate,
|
|
default_payment_terms_days: lexware.default_payment_terms_days,
|
|
};
|
|
|
|
if (newLexwareApiKey) {
|
|
payload.api_key = newLexwareApiKey;
|
|
}
|
|
|
|
const response = await api.put<LexwareSettings>("/lexware/settings", payload);
|
|
setLexware(response.data);
|
|
setNewLexwareApiKey("");
|
|
showToast({ type: "success", title: "Lexware-Konfiguration gespeichert" });
|
|
} catch (err) {
|
|
showToast({
|
|
type: "error",
|
|
title: "Lexware konnte nicht gespeichert werden",
|
|
description: getErrorMessage(err, "Bitte prüfe die Lexware-Einstellungen."),
|
|
});
|
|
} finally {
|
|
setSavingLexware(false);
|
|
}
|
|
}
|
|
|
|
async function testLexwareConnection() {
|
|
setTestingLexware(true);
|
|
try {
|
|
const response = await api.post<LexwareTestConnectionResponse>("/lexware/test-connection");
|
|
showToast({
|
|
type: response.data.success ? "success" : "error",
|
|
title: response.data.success ? "Lexware-Verbindung erfolgreich" : "Lexware-Verbindung fehlgeschlagen",
|
|
description: response.data.message,
|
|
});
|
|
} catch (err) {
|
|
showToast({
|
|
type: "error",
|
|
title: "Lexware-Verbindung fehlgeschlagen",
|
|
description: getErrorMessage(err, "Die Verbindung konnte nicht geprüft werden."),
|
|
});
|
|
} finally {
|
|
setTestingLexware(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
|
}
|
|
|
|
if (error) {
|
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
|
<p className="mt-1 text-sm text-slate-500">SMTP-Versand, öffentliche Statuslinks und Lexware Office verwalten</p>
|
|
</div>
|
|
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
|
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
|
SMTP
|
|
</TabButton>
|
|
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
|
Öffentliche Links
|
|
</TabButton>
|
|
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
|
|
Lexware Office
|
|
</TabButton>
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === "smtp" ? (
|
|
<section className="rounded-lg border bg-white p-6">
|
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<MailCheck className="h-5 w-5 text-slate-500" />
|
|
<h2 className="text-lg font-semibold text-slate-950">SMTP</h2>
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
Quelle: {sourceLabels[smtp.source]}
|
|
{smtp.password_is_set ? " · Passwort ist gesetzt" : ""}
|
|
</p>
|
|
</div>
|
|
<Button type="button" onClick={() => void saveSmtp()} disabled={savingSmtp}>
|
|
<Save />
|
|
{savingSmtp ? "Speichert..." : "Speichern"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-5 lg:grid-cols-2">
|
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300"
|
|
checked={smtp.enabled}
|
|
onChange={(event) => setSmtp((current) => ({ ...current, enabled: event.target.checked }))}
|
|
/>
|
|
SMTP über Admin-Konfiguration aktivieren
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300"
|
|
checked={smtp.use_tls}
|
|
onChange={(event) => setSmtp((current) => ({ ...current, use_tls: event.target.checked }))}
|
|
/>
|
|
TLS / STARTTLS aktiv
|
|
</label>
|
|
|
|
<Field label="SMTP Host">
|
|
<Input value={smtp.host} onChange={(event) => setSmtp((current) => ({ ...current, host: event.target.value }))} />
|
|
</Field>
|
|
|
|
<Field label="Port">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={65535}
|
|
value={smtp.port}
|
|
onChange={(event) => setSmtp((current) => ({ ...current, port: Number(event.target.value) || 587 }))}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Benutzername">
|
|
<Input value={smtp.username} onChange={(event) => setSmtp((current) => ({ ...current, username: event.target.value }))} />
|
|
</Field>
|
|
|
|
<Field label={smtp.password_is_set ? "Neues Passwort setzen" : "Passwort"}>
|
|
<Input
|
|
type="password"
|
|
value={newPassword}
|
|
placeholder={smtp.password_is_set ? "Leer lassen, um aktuelles Passwort zu behalten" : ""}
|
|
onChange={(event) => setNewPassword(event.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Absenderadresse">
|
|
<Input
|
|
type="email"
|
|
value={smtp.from_email}
|
|
onChange={(event) => setSmtp((current) => ({ ...current, from_email: event.target.value }))}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Absendername">
|
|
<Input value={smtp.from_name} onChange={(event) => setSmtp((current) => ({ ...current, from_name: event.target.value }))} />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
|
Für Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS/STARTTLS aktiv,
|
|
Benutzername vollständige Mailadresse, Passwort = app-spezifisches Passwort.
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-lg border bg-slate-50 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<Send className="h-5 w-5 text-slate-500" />
|
|
<h3 className="font-semibold text-slate-950">Testmail senden</h3>
|
|
</div>
|
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
|
<Input
|
|
type="email"
|
|
placeholder="empfaenger@example.com"
|
|
value={testRecipient}
|
|
onChange={(event) => setTestRecipient(event.target.value)}
|
|
/>
|
|
<Button type="button" onClick={() => void sendTestMail()} disabled={sendingTest || !testRecipient}>
|
|
<Send />
|
|
{sendingTest ? "Sendet..." : "Testmail"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : activeTab === "public-links" ? (
|
|
<section className="rounded-lg border bg-white p-6">
|
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<ExternalLink className="h-5 w-5 text-slate-500" />
|
|
<h2 className="text-lg font-semibold text-slate-950">Öffentliche Links</h2>
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">Quelle: {sourceLabels[publicLinks.source]}</p>
|
|
</div>
|
|
<Button type="button" onClick={() => void savePublicLinks()} disabled={savingPublicLinks}>
|
|
<Save />
|
|
{savingPublicLinks ? "Speichert..." : "Speichern"}
|
|
</Button>
|
|
</div>
|
|
|
|
<Field label="Reparaturstatus Basis-URL">
|
|
<Input
|
|
placeholder="https://test.funktechnik-schubert.de/status"
|
|
value={publicLinks.repair_status_base_url}
|
|
onChange={(event) => setPublicLinks((current) => ({ ...current, repair_status_base_url: event.target.value }))}
|
|
/>
|
|
</Field>
|
|
|
|
<div className="mt-5 flex items-start gap-3 rounded-lg border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-950">
|
|
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
|
<p>
|
|
Statusmails erzeugen weiterhin sichere Einmal-Token in Hermes. Die Basis-URL bestimmt nur,
|
|
auf welche öffentliche Website-Route der Link zeigt.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="rounded-lg border bg-white p-6">
|
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<ReceiptText className="h-5 w-5 text-slate-500" />
|
|
<h2 className="text-lg font-semibold text-slate-950">Lexware Office</h2>
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
Quelle: {sourceLabels[lexware.source]}
|
|
{lexware.api_key_is_set ? " · API-Key ist gesetzt" : ""}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" variant="outline" onClick={() => void testLexwareConnection()} disabled={testingLexware}>
|
|
<ShieldCheck />
|
|
{testingLexware ? "Prüft..." : "Verbindung testen"}
|
|
</Button>
|
|
<Button type="button" onClick={() => void saveLexware()} disabled={savingLexware}>
|
|
<Save />
|
|
{savingLexware ? "Speichert..." : "Speichern"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-5 lg:grid-cols-2">
|
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300"
|
|
checked={lexware.enabled}
|
|
onChange={(event) => setLexware((current) => ({ ...current, enabled: event.target.checked }))}
|
|
/>
|
|
Lexware-Integration aktivieren
|
|
</label>
|
|
|
|
<Field label="Organisation">
|
|
<Input value={lexware.organization_name} onChange={(event) => setLexware((current) => ({ ...current, organization_name: event.target.value }))} />
|
|
</Field>
|
|
|
|
<Field label="API Base URL">
|
|
<Input value={lexware.api_base_url} onChange={(event) => setLexware((current) => ({ ...current, api_base_url: event.target.value }))} />
|
|
</Field>
|
|
|
|
<Field label={lexware.api_key_is_set ? "Neuen API-Key setzen" : "API-Key"}>
|
|
<Input
|
|
type="password"
|
|
value={newLexwareApiKey}
|
|
placeholder={lexware.api_key_is_set ? "Leer lassen, um aktuellen API-Key zu behalten" : ""}
|
|
onChange={(event) => setNewLexwareApiKey(event.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Standard MwSt. %">
|
|
<Input
|
|
inputMode="decimal"
|
|
value={lexware.default_tax_rate}
|
|
onChange={(event) => setLexware((current) => ({ ...current, default_tax_rate: event.target.value }))}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Zahlungsziel Tage">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={365}
|
|
value={lexware.default_payment_terms_days}
|
|
onChange={(event) => setLexware((current) => ({ ...current, default_payment_terms_days: Number(event.target.value) || 0 }))}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-start gap-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
|
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
|
<p>
|
|
Olympus bereitet Werkstatt- und KV-Daten vor. Lexware Office bleibt führend für Buchhaltung,
|
|
Rechnungen, Steuer und DATEV/EÜR. Der API-Key wird nicht angezeigt und nicht an den Browser zurückgegeben.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TabButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
active ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
|
|
}`}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|