feat(lexware): add integration foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 13:10:27 +02:00
parent 8d37eb29b3
commit ffffb68898
28 changed files with 1150 additions and 11 deletions

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/lexware/settings");
}
export async function PUT(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/lexware/settings");
}

View file

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/lexware/test-connection");
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/lexware/prepare-invoice`);
}

View file

@ -176,6 +176,7 @@ export default function RepairDetailPage({ params }: Params) {
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke");
const canLexwareExport = hasPermission(currentUser, "lexware.export");
async function createPublicLink() {
if (!repair || !canManagePublicLink) return;
@ -444,6 +445,7 @@ export default function RepairDetailPage({ params }: Params) {
canDelete={canDeleteEstimates}
canSend={canSendEstimates}
canRevoke={canRevokeEstimates}
canLexwareExport={canLexwareExport}
/>
</DetailSection>

View file

@ -2,12 +2,17 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-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,
@ -16,7 +21,7 @@ import type {
SmtpTestResponse,
} from "@/types/system-settings";
type SettingsTab = "smtp" | "public-links";
type SettingsTab = "smtp" | "public-links" | "lexware";
const sourceLabels: Record<SettingsSource, string> = {
database: "Admin-Konfiguration",
@ -46,6 +51,18 @@ function emptySmtpSettings(): SmtpSettings {
};
}
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");
@ -56,22 +73,28 @@ export default function SettingsPage() {
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] = await Promise.all([
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 {
@ -158,6 +181,56 @@ export default function SettingsPage() {
}
}
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>;
}
@ -171,7 +244,7 @@ export default function SettingsPage() {
<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 und öffentliche Statuslinks verwalten</p>
<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")}>
@ -180,6 +253,9 @@ export default function SettingsPage() {
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
Öffentliche Links
</TabButton>
<TabButton active={activeTab === "lexware"} onClick={() => setActiveTab("lexware")}>
Lexware Office
</TabButton>
</div>
</div>
@ -287,7 +363,7 @@ export default function SettingsPage() {
</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>
@ -319,6 +395,86 @@ export default function SettingsPage() {
</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>
);