feat(accounting): add invoice preparation workflow
This commit is contained in:
parent
ffffb68898
commit
46eeaa1f2e
17 changed files with 519 additions and 44 deletions
|
|
@ -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}/accounting/mark-transferred`);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
|
|
@ -46,6 +46,20 @@ const statusLabels: Record<string, string> = {
|
|||
revoked: "Zurückgenommen",
|
||||
};
|
||||
|
||||
const accountingStatusLabels: Record<string, string> = {
|
||||
prepared: "Vorbereitet",
|
||||
transferred: "Übertragen",
|
||||
booked: "Gebucht",
|
||||
cancelled: "Storniert",
|
||||
};
|
||||
|
||||
const accountingStatusClasses: Record<string, string> = {
|
||||
prepared: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
transferred: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
booked: "bg-slate-900 text-white ring-slate-900/20",
|
||||
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
|
||||
};
|
||||
|
||||
function humanizeValidationDetail(detail: unknown): string | null {
|
||||
if (!Array.isArray(detail)) {
|
||||
return null;
|
||||
|
|
@ -193,6 +207,8 @@ export default function RepairEstimatesSection({
|
|||
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
||||
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
|
||||
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
|
||||
const [accountingTarget, setAccountingTarget] = useState<RepairEstimate | null>(null);
|
||||
const [accountingNote, setAccountingNote] = useState("");
|
||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||
|
|
@ -417,19 +433,48 @@ export default function RepairEstimatesSection({
|
|||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
|
||||
setLexwareResult(response.data);
|
||||
setAccountingTarget(estimate);
|
||||
setAccountingNote(response.data.accounting_note || estimate.accounting_note || "");
|
||||
setLexwareDialogOpen(true);
|
||||
showToast({
|
||||
type: response.data.ready_for_export ? "success" : "error",
|
||||
title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen",
|
||||
description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.",
|
||||
title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen",
|
||||
description: response.data.ready_for_export ? "Die Daten stehen für die Übernahme in die Buchhaltung bereit." : "Bitte prüfe die Hinweise vor der Übernahme.",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) });
|
||||
showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) });
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAccountingTransferred() {
|
||||
if (!accountingTarget || !lexwareResult) return;
|
||||
setPendingId(accountingTarget.id);
|
||||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(
|
||||
`/repairs/${repairId}/estimates/${accountingTarget.id}/accounting/mark-transferred`,
|
||||
{ accounting_note: accountingNote },
|
||||
);
|
||||
setLexwareResult(response.data);
|
||||
await loadEstimates();
|
||||
showToast({ type: "success", title: "Als übertragen markiert", description: "Die Übergabe an die Buchhaltung wurde dokumentiert." });
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Status konnte nicht aktualisiert werden", description: getErrorMessage(err) });
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(label: string, text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast({ type: "success", title: `${label} kopiert` });
|
||||
} catch {
|
||||
showToast({ type: "error", title: "Kopieren fehlgeschlagen", description: "Bitte markiere den Text manuell." });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEstimate() {
|
||||
if (!deleteTarget) return;
|
||||
setPendingId(deleteTarget.id);
|
||||
|
|
@ -494,6 +539,16 @@ export default function RepairEstimatesSection({
|
|||
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
|
||||
</dl>
|
||||
|
||||
{estimate.accounting_export_status && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
|
||||
<ReceiptText className="h-4 w-4 text-slate-500" />
|
||||
<span className="font-medium text-slate-700">Buchhaltung</span>
|
||||
<AccountingBadge status={estimate.accounting_export_status} />
|
||||
{estimate.accounting_transferred_at && <span className="text-xs text-slate-500">{dateTime(estimate.accounting_transferred_at)}</span>}
|
||||
{estimate.accounting_note && <span className="break-words text-xs text-slate-500">{estimate.accounting_note}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 divide-y rounded-lg border">
|
||||
{estimate.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
||||
|
|
@ -519,7 +574,7 @@ export default function RepairEstimatesSection({
|
|||
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
||||
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
||||
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
|
||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />Lexware-Rechnung vorbereiten</Button>}
|
||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />In Buchhaltung übernehmen</Button>}
|
||||
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -656,14 +711,22 @@ export default function RepairEstimatesSection({
|
|||
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lexware-Rechnung vorbereiten</DialogTitle>
|
||||
<DialogDescription>Die Rechnung wird noch nicht automatisch in Lexware erstellt.</DialogDescription>
|
||||
<DialogTitle>In Buchhaltung übernehmen</DialogTitle>
|
||||
<DialogDescription>Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{lexwareResult && (
|
||||
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
|
||||
<div className={`rounded-lg border p-4 ${lexwareResult.ready_for_export ? "bg-emerald-50 text-emerald-950" : "bg-amber-50 text-amber-950"}`}>
|
||||
<p className="font-semibold">{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}</p>
|
||||
<p className="mt-1">Sync-Record #{lexwareResult.sync_record_id}</p>
|
||||
<div className="rounded-lg border bg-slate-50 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-950">Exportstatus</span>
|
||||
<AccountingBadge status={lexwareResult.export_status} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<AccountingBadge status="transferred" muted={lexwareResult.export_status !== "transferred" && lexwareResult.export_status !== "booked"} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<AccountingBadge status="booked" muted={lexwareResult.export_status !== "booked"} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">Rechnungsvorbereitung #{lexwareResult.sync_record_id}</p>
|
||||
{lexwareResult.transferred_at && <p className="mt-1 text-xs text-slate-500">Übertragen am {dateTime(lexwareResult.transferred_at)}</p>}
|
||||
</div>
|
||||
|
||||
{lexwareResult.warnings.length > 0 && (
|
||||
|
|
@ -675,14 +738,21 @@ export default function RepairEstimatesSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-2">
|
||||
<Meta label="Kunde" value={lexwareResult.customer_mapping.name} />
|
||||
<Meta label="E-Mail" value={lexwareResult.customer_mapping.email || "-"} />
|
||||
<Meta label="Telefon" value={lexwareResult.customer_mapping.phone || "-"} />
|
||||
<Meta label="Kontaktabgleich" value={lexwareResult.customer_mapping.search_strategy} />
|
||||
<div className="rounded-lg border">
|
||||
<StepHeader step="1" title="Kundendaten kopieren" />
|
||||
<CopyBlock
|
||||
text={[
|
||||
`Kunde: ${lexwareResult.customer_mapping.name}`,
|
||||
`E-Mail: ${lexwareResult.customer_mapping.email || "-"}`,
|
||||
`Telefon: ${lexwareResult.customer_mapping.phone || "-"}`,
|
||||
].join("\n")}
|
||||
onCopy={(text) => void copyText("Kundendaten", text)}
|
||||
/>
|
||||
<StepHeader step="2" title="In Buchhaltungssoftware einfügen" muted />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<StepHeader step="3" title="Positionen kopieren" />
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
|
||||
<span>Position</span>
|
||||
<span>Steuer</span>
|
||||
|
|
@ -698,6 +768,13 @@ export default function RepairEstimatesSection({
|
|||
<span className="font-medium text-slate-950">{item.total} €</span>
|
||||
</div>
|
||||
))}
|
||||
<CopyBlock
|
||||
text={lexwareResult.line_item_mapping.map((item) => (
|
||||
`${item.title}; ${item.description || "-"}; ${item.quantity} ${item.unit}; ${item.unit_price} EUR; ${item.tax_rate}%; ${item.total} EUR`
|
||||
)).join("\n")}
|
||||
onCopy={(text) => void copyText("Positionen", text)}
|
||||
/>
|
||||
<StepHeader step="4" title="In Buchhaltungssoftware einfügen" muted />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
|
||||
|
|
@ -705,6 +782,33 @@ export default function RepairEstimatesSection({
|
|||
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")} €`} />
|
||||
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")} €`} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white">
|
||||
<StepHeader step="5" title="Rechnung speichern" muted />
|
||||
<StepHeader step="6" title="Zurück zu Olympus" muted />
|
||||
<div className="border-t p-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Buchhaltungsnotiz</span>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
|
||||
placeholder="z. B. Lexware RG-2026-154 oder Rechnung in sevdesk erstellt"
|
||||
value={accountingNote}
|
||||
onChange={(event) => setAccountingNote(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void markAccountingTransferred()}
|
||||
disabled={!accountingTarget || pendingId === accountingTarget.id || lexwareResult.export_status === "transferred"}
|
||||
>
|
||||
<CheckCircle2 />
|
||||
Als übertragen markieren
|
||||
</Button>
|
||||
{lexwareResult.export_status === "transferred" && <span className="text-sm font-medium text-emerald-700">Bereits übertragen</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
|
@ -760,6 +864,37 @@ function Meta({ label, value }: { label: string; value: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AccountingBadge({ status, muted = false }: { status: string; muted?: boolean }) {
|
||||
const label = accountingStatusLabels[status] ?? status;
|
||||
const activeClass = accountingStatusClasses[status] ?? "bg-slate-100 text-slate-700 ring-slate-600/20";
|
||||
return (
|
||||
<span className={`w-fit rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${muted ? "bg-slate-100 text-slate-400 ring-slate-200" : activeClass}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({ step, title, muted = false }: { step: string; title: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 border-b p-3 ${muted ? "bg-slate-50 text-slate-500" : "bg-white text-slate-950"}`}>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{step}</span>
|
||||
<p className="font-medium">{title}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBlock({ text, onCopy }: { text: string; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-3 p-3">
|
||||
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-white whitespace-pre-wrap">{text}</pre>
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => onCopy(text)}>
|
||||
<Copy />
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
|
|
|
|||
|
|
@ -47,10 +47,14 @@ export interface LexwareLineItemMapping {
|
|||
|
||||
export interface LexwareInvoicePreparation {
|
||||
ready_for_export: boolean;
|
||||
export_status: "prepared" | "transferred" | "booked" | "cancelled";
|
||||
payload_summary: Record<string, unknown>;
|
||||
customer_mapping: LexwareCustomerMapping;
|
||||
line_item_mapping: LexwareLineItemMapping[];
|
||||
tax_mapping: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
sync_record_id: number;
|
||||
accounting_note: string;
|
||||
transferred_at: string | null;
|
||||
transferred_by_user_id: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,10 @@ export interface RepairEstimate {
|
|||
lexware_invoice_number: string | null;
|
||||
lexware_invoice_status: string | null;
|
||||
lexware_synced_at: string | null;
|
||||
accounting_export_status: "prepared" | "transferred" | "booked" | "cancelled" | null;
|
||||
accounting_note: string | null;
|
||||
accounting_transferred_at: string | null;
|
||||
accounting_transferred_by_user_id: number | null;
|
||||
created_by_user_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue