feat(lexware): add integration foundation
This commit is contained in:
parent
8d37eb29b3
commit
ffffb68898
28 changed files with 1150 additions and 11 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
InventoryCategory,
|
||||
InventoryItem,
|
||||
} from "@/types/inventory";
|
||||
import type { LexwareInvoicePreparation } from "@/types/lexware";
|
||||
import type {
|
||||
RepairEstimate,
|
||||
RepairEstimateItemType,
|
||||
|
|
@ -164,6 +165,7 @@ type Props = {
|
|||
canDelete: boolean;
|
||||
canSend: boolean;
|
||||
canRevoke: boolean;
|
||||
canLexwareExport: boolean;
|
||||
};
|
||||
|
||||
export default function RepairEstimatesSection({
|
||||
|
|
@ -175,6 +177,7 @@ export default function RepairEstimatesSection({
|
|||
canDelete,
|
||||
canSend,
|
||||
canRevoke,
|
||||
canLexwareExport,
|
||||
}: Props) {
|
||||
const { showToast } = useToast();
|
||||
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
||||
|
|
@ -188,6 +191,8 @@ export default function RepairEstimatesSection({
|
|||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
||||
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
|
||||
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
|
||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||
|
|
@ -407,6 +412,24 @@ export default function RepairEstimatesSection({
|
|||
}
|
||||
}
|
||||
|
||||
async function prepareLexwareInvoice(estimate: RepairEstimate) {
|
||||
setPendingId(estimate.id);
|
||||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
|
||||
setLexwareResult(response.data);
|
||||
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.",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) });
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEstimate() {
|
||||
if (!deleteTarget) return;
|
||||
setPendingId(deleteTarget.id);
|
||||
|
|
@ -496,6 +519,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>}
|
||||
{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>
|
||||
|
|
@ -629,6 +653,66 @@ export default function RepairEstimatesSection({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{lexwareResult.warnings.length > 0 && (
|
||||
<div className="rounded-lg border bg-amber-50 p-4 text-amber-950">
|
||||
<p className="font-semibold">Warnungen</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{lexwareResult.warnings.map((warning) => <li key={warning}>{warning}</li>)}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<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>
|
||||
<span>Summe</span>
|
||||
</div>
|
||||
{lexwareResult.line_item_mapping.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className="grid grid-cols-[1fr_auto_auto] gap-3 border-b p-3 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{item.title}</p>
|
||||
<p className="text-xs text-slate-500">{item.quantity} {item.unit} × {item.unit_price} €</p>
|
||||
</div>
|
||||
<span>{item.tax_rate} %</span>
|
||||
<span className="font-medium text-slate-950">{item.total} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
|
||||
<Meta label="Netto" value={`${String(lexwareResult.payload_summary.subtotal ?? "-")} €`} />
|
||||
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")} €`} />
|
||||
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")} €`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setLexwareDialogOpen(false)}>Schließen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(cancelTarget)}
|
||||
title="Kostenvoranschlag stornieren?"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue