905 lines
43 KiB
TypeScript
905 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import type { ReactNode } from "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";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { api } from "@/lib/api";
|
||
import type {
|
||
InventoryCategory,
|
||
InventoryItem,
|
||
} from "@/types/inventory";
|
||
import type { LexwareInvoicePreparation } from "@/types/lexware";
|
||
import type {
|
||
RepairEstimate,
|
||
RepairEstimateItemType,
|
||
RepairEstimatePayload,
|
||
} from "@/types/repair";
|
||
|
||
const itemTypeLabels: Record<RepairEstimateItemType, string> = {
|
||
labor: "Arbeitszeit",
|
||
part: "Ersatzteil",
|
||
flat_rate: "Pauschale",
|
||
shipping: "Versand",
|
||
other: "Sonstiges",
|
||
};
|
||
|
||
const statusLabels: Record<string, string> = {
|
||
draft: "Entwurf",
|
||
sent: "Gesendet",
|
||
approved: "Freigegeben",
|
||
declined: "Abgelehnt",
|
||
expired: "Abgelaufen",
|
||
cancelled: "Storniert",
|
||
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;
|
||
}
|
||
|
||
const messages = detail
|
||
.map((item) => {
|
||
if (typeof item !== "object" || item === null) {
|
||
return null;
|
||
}
|
||
const record = item as { loc?: unknown[]; msg?: unknown };
|
||
const field = Array.isArray(record.loc) ? record.loc.filter((part) => part !== "body").join(".") : "";
|
||
const message = typeof record.msg === "string" ? record.msg : "Ungültiger Wert";
|
||
return field ? `${field}: ${message}` : message;
|
||
})
|
||
.filter((message): message is string => Boolean(message));
|
||
|
||
return messages.length ? messages.join(" · ") : null;
|
||
}
|
||
|
||
function getErrorMessage(error: unknown) {
|
||
if (typeof error === "object" && error !== null && "response" in error) {
|
||
const response = (error as { response?: { data?: { detail?: unknown; message?: unknown } } }).response;
|
||
const detailMessage = humanizeValidationDetail(response?.data?.detail);
|
||
if (detailMessage) {
|
||
return detailMessage;
|
||
}
|
||
if (typeof response?.data?.detail === "string") {
|
||
return response.data.detail;
|
||
}
|
||
if (typeof response?.data?.message === "string") {
|
||
return response.data.message;
|
||
}
|
||
return "Bitte prüfe die Eingaben. Mindestens eine gültige Position mit Titel, Menge und Preis ist erforderlich.";
|
||
}
|
||
return "Aktion konnte nicht abgeschlossen werden";
|
||
}
|
||
|
||
function money(cents: number, currency = "EUR") {
|
||
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
|
||
}
|
||
|
||
function dateTime(value: string | null) {
|
||
if (!value) return "-";
|
||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||
}
|
||
|
||
type EstimateFormItem = {
|
||
item_type: RepairEstimateItemType;
|
||
inventory_item_id: number | null;
|
||
inventory_snapshot_name: string;
|
||
inventory_snapshot_sku: string;
|
||
inventory_snapshot_manufacturer: string | null;
|
||
inventory_snapshot_part_number: string | null;
|
||
inventory_price_overridden: boolean;
|
||
title: string;
|
||
description: string | null;
|
||
quantity: string;
|
||
unit: string;
|
||
unit_price_euros: string;
|
||
};
|
||
|
||
type EstimateFormPayload = Omit<RepairEstimatePayload, "items"> & {
|
||
items: EstimateFormItem[];
|
||
};
|
||
|
||
function parseEuroToCents(value: string): number | null {
|
||
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
|
||
return null;
|
||
}
|
||
return Math.round(Number(normalized) * 100);
|
||
}
|
||
|
||
function centsToEuroInput(cents: number): string {
|
||
return (cents / 100).toFixed(2).replace(".", ",");
|
||
}
|
||
|
||
function emptyItem(): EstimateFormItem {
|
||
return {
|
||
item_type: "labor",
|
||
inventory_item_id: null,
|
||
inventory_snapshot_name: "",
|
||
inventory_snapshot_sku: "",
|
||
inventory_snapshot_manufacturer: null,
|
||
inventory_snapshot_part_number: null,
|
||
inventory_price_overridden: false,
|
||
title: "",
|
||
description: "",
|
||
quantity: "1.00",
|
||
unit: "Std.",
|
||
unit_price_euros: "",
|
||
};
|
||
}
|
||
|
||
function emptyPayload(): EstimateFormPayload {
|
||
return {
|
||
title: "Kostenvoranschlag",
|
||
customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.",
|
||
internal_note: "",
|
||
tax_rate_percent: "19.00",
|
||
currency: "EUR",
|
||
valid_until: null,
|
||
items: [emptyItem()],
|
||
};
|
||
}
|
||
|
||
type Props = {
|
||
repairId: number;
|
||
customerEmail: string;
|
||
canRead: boolean;
|
||
canCreate: boolean;
|
||
canUpdate: boolean;
|
||
canDelete: boolean;
|
||
canSend: boolean;
|
||
canRevoke: boolean;
|
||
canLexwareExport: boolean;
|
||
};
|
||
|
||
export default function RepairEstimatesSection({
|
||
repairId,
|
||
customerEmail,
|
||
canRead,
|
||
canCreate,
|
||
canUpdate,
|
||
canDelete,
|
||
canSend,
|
||
canRevoke,
|
||
canLexwareExport,
|
||
}: Props) {
|
||
const { showToast } = useToast();
|
||
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editing, setEditing] = useState<RepairEstimate | null>(null);
|
||
const [payload, setPayload] = useState<EstimateFormPayload>(emptyPayload());
|
||
const [saving, setSaving] = useState(false);
|
||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||
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 [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[]>([]);
|
||
const [inventorySearch, setInventorySearch] = useState("");
|
||
const [inventoryCategory, setInventoryCategory] = useState("all");
|
||
const [inventoryManufacturer, setInventoryManufacturer] = useState("");
|
||
const [inventoryLoading, setInventoryLoading] = useState(false);
|
||
const [inventoryError, setInventoryError] = useState("");
|
||
|
||
const loadEstimates = useCallback(async () => {
|
||
if (!canRead) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const response = await api.get<RepairEstimate[]>(`/repairs/${repairId}/estimates`);
|
||
setEstimates(response.data);
|
||
} catch (err) {
|
||
setError(getErrorMessage(err));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [canRead, repairId]);
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void loadEstimates();
|
||
});
|
||
}, [loadEstimates]);
|
||
|
||
const searchInventoryItems = useCallback(async () => {
|
||
setInventoryLoading(true);
|
||
setInventoryError("");
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("limit", "20");
|
||
if (inventorySearch.trim()) params.set("q", inventorySearch.trim());
|
||
if (inventoryCategory !== "all") params.set("category", inventoryCategory);
|
||
if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim());
|
||
const [itemsResponse, categoriesResponse] = await Promise.all([
|
||
api.get<InventoryItem[]>(`/inventory/items/search?${params.toString()}`),
|
||
api.get<InventoryCategory[]>("/inventory/categories"),
|
||
]);
|
||
setInventoryItems(itemsResponse.data);
|
||
setInventoryCategories(categoriesResponse.data);
|
||
} catch (err) {
|
||
setInventoryError(getErrorMessage(err));
|
||
} finally {
|
||
setInventoryLoading(false);
|
||
}
|
||
}, [inventoryCategory, inventoryManufacturer, inventorySearch]);
|
||
|
||
useEffect(() => {
|
||
if (!inventoryDialogOpen) {
|
||
return;
|
||
}
|
||
const id = window.setTimeout(() => {
|
||
void searchInventoryItems();
|
||
}, 200);
|
||
return () => window.clearTimeout(id);
|
||
}, [inventoryDialogOpen, searchInventoryItems]);
|
||
|
||
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
||
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
||
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
||
return sum + Math.round(quantity * unitPriceCents);
|
||
}, 0), [payload.items]);
|
||
const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100);
|
||
|
||
function openCreateDialog() {
|
||
setEditing(null);
|
||
setPayload(emptyPayload());
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openEditDialog(estimate: RepairEstimate) {
|
||
setEditing(estimate);
|
||
setPayload({
|
||
title: estimate.title,
|
||
customer_message: estimate.customer_message,
|
||
internal_note: estimate.internal_note ?? "",
|
||
tax_rate_percent: String(estimate.tax_rate_percent),
|
||
currency: estimate.currency,
|
||
valid_until: estimate.valid_until,
|
||
items: estimate.items.map((item) => ({
|
||
item_type: item.item_type,
|
||
inventory_item_id: item.inventory_item_id ?? null,
|
||
inventory_snapshot_name: item.inventory_snapshot_name,
|
||
inventory_snapshot_sku: item.inventory_snapshot_sku,
|
||
inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer,
|
||
inventory_snapshot_part_number: item.inventory_snapshot_part_number,
|
||
inventory_price_overridden: false,
|
||
title: item.title,
|
||
description: item.description ?? "",
|
||
quantity: String(item.quantity),
|
||
unit: item.unit,
|
||
unit_price_euros: centsToEuroInput(item.unit_price_cents),
|
||
})),
|
||
});
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function updateItem(index: number, update: Partial<EstimateFormItem>) {
|
||
setPayload((current) => ({
|
||
...current,
|
||
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
|
||
}));
|
||
}
|
||
|
||
function addInventoryItem(item: InventoryItem) {
|
||
const formItem: EstimateFormItem = {
|
||
item_type: "part",
|
||
inventory_item_id: item.id,
|
||
inventory_snapshot_name: item.name,
|
||
inventory_snapshot_sku: item.sku,
|
||
inventory_snapshot_manufacturer: item.manufacturer,
|
||
inventory_snapshot_part_number: item.manufacturer_part_number,
|
||
inventory_price_overridden: false,
|
||
title: item.name,
|
||
description: item.description ?? "",
|
||
quantity: "1",
|
||
unit: item.unit,
|
||
unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0),
|
||
};
|
||
setPayload((current) => ({ ...current, items: [...current.items, formItem] }));
|
||
setInventoryDialogOpen(false);
|
||
}
|
||
|
||
async function saveEstimate() {
|
||
setSaving(true);
|
||
try {
|
||
const invalidPriceIndex = payload.items.findIndex((item) => parseEuroToCents(item.unit_price_euros) === null);
|
||
if (invalidPriceIndex >= 0) {
|
||
showToast({
|
||
type: "error",
|
||
title: "Preisangabe ungültig",
|
||
description: `Bitte gib den Einzelpreis in Position ${invalidPriceIndex + 1} als Eurobetrag ein, z. B. 100,00.`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const normalizedPayload = {
|
||
title: payload.title.trim(),
|
||
customer_message: payload.customer_message.trim(),
|
||
internal_note: payload.internal_note?.trim() || null,
|
||
tax_rate_percent: payload.tax_rate_percent.replace(",", "."),
|
||
currency: payload.currency.trim().toUpperCase() || "EUR",
|
||
valid_until: payload.valid_until || null,
|
||
items: payload.items.map((item) => ({
|
||
item_type: item.item_type,
|
||
inventory_item_id: item.inventory_item_id,
|
||
inventory_price_overridden: item.inventory_price_overridden,
|
||
title: item.title.trim(),
|
||
description: item.description?.trim() || null,
|
||
quantity: item.quantity.replace(",", "."),
|
||
unit: item.unit.trim() || "Stk.",
|
||
unit_price_cents: parseEuroToCents(item.unit_price_euros) ?? 0,
|
||
})),
|
||
};
|
||
if (editing) {
|
||
await api.put<RepairEstimate>(`/repairs/${repairId}/estimates/${editing.id}`, normalizedPayload);
|
||
} else {
|
||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates`, normalizedPayload);
|
||
}
|
||
await loadEstimates();
|
||
setDialogOpen(false);
|
||
showToast({ type: "success", title: editing ? "Kostenvoranschlag gespeichert" : "Kostenvoranschlag erstellt" });
|
||
} catch (err) {
|
||
showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gespeichert werden", description: getErrorMessage(err) });
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function sendEstimate(estimate: RepairEstimate) {
|
||
setPendingId(estimate.id);
|
||
try {
|
||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/send`);
|
||
await loadEstimates();
|
||
showToast({ type: "success", title: "Kostenvoranschlag gesendet", description: "Der Versandversuch wurde dokumentiert." });
|
||
} catch (err) {
|
||
showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gesendet werden", description: getErrorMessage(err) });
|
||
} finally {
|
||
setPendingId(null);
|
||
}
|
||
}
|
||
|
||
async function cancelEstimate() {
|
||
if (!cancelTarget) return;
|
||
setPendingId(cancelTarget.id);
|
||
try {
|
||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`);
|
||
await loadEstimates();
|
||
setCancelTarget(null);
|
||
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
|
||
} catch (err) {
|
||
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
|
||
} finally {
|
||
setPendingId(null);
|
||
}
|
||
}
|
||
|
||
async function revokeEstimate() {
|
||
if (!revokeTarget) return;
|
||
setPendingId(revokeTarget.id);
|
||
try {
|
||
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${revokeTarget.id}/revoke`);
|
||
await loadEstimates();
|
||
setRevokeTarget(null);
|
||
showToast({ type: "success", title: "Freigabe zurückgenommen", description: "Die Reservierungen wurden freigegeben." });
|
||
} catch (err) {
|
||
showToast({ type: "error", title: "Freigabe konnte nicht zurückgenommen werden", description: getErrorMessage(err) });
|
||
} finally {
|
||
setPendingId(null);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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 ? "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: "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);
|
||
try {
|
||
await api.delete(`/repairs/${repairId}/estimates/${deleteTarget.id}`);
|
||
await loadEstimates();
|
||
setDeleteTarget(null);
|
||
showToast({ type: "success", title: "Kostenvoranschlag gelöscht" });
|
||
} catch (err) {
|
||
showToast({ type: "error", title: "Löschen fehlgeschlagen", description: getErrorMessage(err) });
|
||
} finally {
|
||
setPendingId(null);
|
||
}
|
||
}
|
||
|
||
if (!canRead) {
|
||
return <p className="text-sm text-slate-500">Keine Berechtigung für Kostenvoranschläge.</p>;
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Kostenvoranschläge werden geladen...</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm text-slate-500">{estimates.length} Kostenvoranschlag{estimates.length === 1 ? "" : "e"}</p>
|
||
{canCreate && <Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button>}
|
||
</div>
|
||
|
||
{estimates.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
|
||
<h3 className="text-lg font-semibold text-slate-950">Noch kein Kostenvoranschlag vorhanden.</h3>
|
||
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">Erstelle Positionen, lasse Olympus serverseitig summieren und sende den KV per Statuslink an den Kunden.</p>
|
||
{canCreate && <div className="mt-5"><Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button></div>}
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{estimates.map((estimate) => (
|
||
<article key={estimate.id} className="rounded-lg border bg-white p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<FileCheck2 className="h-5 w-5 text-slate-500" />
|
||
<h3 className="font-semibold text-slate-950">{estimate.estimate_number}</h3>
|
||
</div>
|
||
<p className="mt-1 text-sm text-slate-600">{estimate.title}</p>
|
||
</div>
|
||
<span className="w-fit rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">{statusLabels[estimate.status]}</span>
|
||
</div>
|
||
|
||
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||
<Meta label="Summe netto" value={money(estimate.subtotal_cents, estimate.currency)} />
|
||
<Meta label="MwSt." value={money(estimate.tax_cents, estimate.currency)} />
|
||
<Meta label="Gesamt" value={money(estimate.total_cents, estimate.currency)} />
|
||
<Meta label="Gültig bis" value={estimate.valid_until ?? "-"} />
|
||
<Meta label="Gesendet" value={dateTime(estimate.sent_at)} />
|
||
<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">
|
||
<div>
|
||
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
||
<p className="text-xs text-slate-500">
|
||
{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""}
|
||
{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}
|
||
</p>
|
||
{item.inventory_item_id && (
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
|
||
{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 />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>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="sm:max-w-4xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{editing ? "Kostenvoranschlag bearbeiten" : "Kostenvoranschlag anlegen"}</DialogTitle>
|
||
<DialogDescription>Summen werden nach dem Speichern serverseitig berechnet.</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="grid max-h-[70vh] gap-5 overflow-y-auto pr-1">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Titel"><Input value={payload.title} onChange={(event) => setPayload((current) => ({ ...current, title: event.target.value }))} /></Field>
|
||
<Field label="Gültig bis"><Input type="date" value={payload.valid_until ?? ""} onChange={(event) => setPayload((current) => ({ ...current, valid_until: event.target.value || null }))} /></Field>
|
||
<Field label="MwSt. %"><Input value={payload.tax_rate_percent} onChange={(event) => setPayload((current) => ({ ...current, tax_rate_percent: event.target.value }))} /></Field>
|
||
<Field label="Währung"><Input value={payload.currency} onChange={(event) => setPayload((current) => ({ ...current, currency: event.target.value.toUpperCase() }))} /></Field>
|
||
</div>
|
||
<Field label="Kundennachricht"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.customer_message} onChange={(event) => setPayload((current) => ({ ...current, customer_message: event.target.value }))} /></Field>
|
||
<Field label="Interne Notiz"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.internal_note ?? ""} onChange={(event) => setPayload((current) => ({ ...current, internal_note: event.target.value }))} /></Field>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setInventoryDialogOpen(true)}><PackageSearch />Lagerartikel auswählen</Button>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
||
</div>
|
||
</div>
|
||
{payload.items.map((item, index) => (
|
||
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
||
{item.inventory_item_id && (
|
||
<div className="mb-3 flex flex-col gap-2 rounded-lg border bg-white p-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<span className="font-semibold text-slate-950">Lagerartikel</span> · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"}
|
||
</div>
|
||
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||
<input type="checkbox" checked={item.inventory_price_overridden} onChange={(event) => updateItem(index, { inventory_price_overridden: event.target.checked })} />
|
||
Preis manuell überschreiben
|
||
</label>
|
||
</div>
|
||
)}
|
||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} disabled={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
||
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||
</select>
|
||
<Input placeholder="Titel" value={item.title} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
||
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
||
<Input placeholder="Einheit" value={item.unit} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
||
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} readOnly={Boolean(item.inventory_item_id && !item.inventory_price_overridden)} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
||
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
||
</div>
|
||
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 text-sm sm:grid-cols-3">
|
||
<Meta label="Netto Vorschau" value={money(clientSubtotal, payload.currency || "EUR")} />
|
||
<Meta label="MwSt. Vorschau" value={money(clientTax, payload.currency || "EUR")} />
|
||
<Meta label="Gesamt Vorschau" value={money(clientSubtotal + clientTax, payload.currency || "EUR")} />
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>Abbrechen</Button>
|
||
<Button type="button" onClick={() => void saveEstimate()} disabled={saving}>{saving ? "Speichert..." : "Speichern"}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={inventoryDialogOpen} onOpenChange={setInventoryDialogOpen}>
|
||
<DialogContent className="sm:max-w-4xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Lagerartikel auswählen</DialogTitle>
|
||
<DialogDescription>Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft.</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="grid gap-4">
|
||
<div className="grid gap-3 md:grid-cols-[1fr_220px_180px]">
|
||
<Input placeholder="SKU, Name, Hersteller oder Gerät suchen" value={inventorySearch} onChange={(event) => setInventorySearch(event.target.value)} />
|
||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={inventoryCategory} onChange={(event) => setInventoryCategory(event.target.value)}>
|
||
<option value="all">Alle Kategorien</option>
|
||
{inventoryCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||
</select>
|
||
<Input placeholder="Hersteller" value={inventoryManufacturer} onChange={(event) => setInventoryManufacturer(event.target.value)} />
|
||
</div>
|
||
|
||
{inventoryError ? (
|
||
<p className="rounded-lg border bg-red-50 p-3 text-sm text-red-700">{inventoryError}</p>
|
||
) : inventoryLoading ? (
|
||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Lagerartikel werden geladen...</p>
|
||
) : inventoryItems.length === 0 ? (
|
||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Keine aktiven Lagerartikel gefunden.</p>
|
||
) : (
|
||
<div className="max-h-[48vh] divide-y overflow-y-auto rounded-lg border">
|
||
{inventoryItems.map((item) => {
|
||
const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="grid w-full gap-2 p-3 text-left hover:bg-slate-50 md:grid-cols-[1fr_auto]"
|
||
onClick={() => addInventoryItem(item)}
|
||
>
|
||
<div>
|
||
<p className="font-medium text-slate-950">{item.sku} · {item.name}</p>
|
||
<p className="text-sm text-slate-500">
|
||
{item.manufacturer || "Hersteller nicht angegeben"} · Verfügbar {item.quantity_available} {item.unit} · {item.location?.name ?? "Kein Lagerort"}
|
||
</p>
|
||
{lowAfterSelection && (
|
||
<p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-amber-700">
|
||
<AlertTriangle size={14} /> Auswahl erreicht oder unterschreitet den Mindestbestand.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span className="font-semibold text-slate-950">{money(item.selling_price_cents ?? 0, item.currency)}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setInventoryDialogOpen(false)}>Schließen</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
|
||
<DialogContent className="sm:max-w-3xl">
|
||
<DialogHeader>
|
||
<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 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 && (
|
||
<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="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>
|
||
<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>
|
||
))}
|
||
<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">
|
||
<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 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>
|
||
<Button type="button" variant="outline" onClick={() => setLexwareDialogOpen(false)}>Schließen</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(cancelTarget)}
|
||
title="Kostenvoranschlag stornieren?"
|
||
description="Der Kostenvoranschlag wird storniert. Reservierte Lagerartikel werden freigegeben."
|
||
confirmLabel="Stornieren"
|
||
pending={pendingId === cancelTarget?.id}
|
||
onOpenChange={(open) => !open && setCancelTarget(null)}
|
||
onConfirm={() => void cancelEstimate()}
|
||
>
|
||
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
|
||
</ConfirmDialog>
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(revokeTarget)}
|
||
title="Freigabe zurücknehmen?"
|
||
description="Möchten Sie den bereits freigegebenen Kostenvoranschlag wirklich zurücknehmen? Die Lagerreservierungen werden freigegeben und der Kunde erhält eine Benachrichtigung."
|
||
confirmLabel="Freigabe zurücknehmen"
|
||
pending={pendingId === revokeTarget?.id}
|
||
onOpenChange={(open) => !open && setRevokeTarget(null)}
|
||
onConfirm={() => void revokeEstimate()}
|
||
>
|
||
{revokeTarget && <p className="text-sm text-slate-600">{revokeTarget.estimate_number} · {revokeTarget.title}</p>}
|
||
</ConfirmDialog>
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(deleteTarget)}
|
||
title="Kostenvoranschlag löschen?"
|
||
description="Der Entwurf wird dauerhaft entfernt."
|
||
pending={pendingId === deleteTarget?.id}
|
||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||
onConfirm={() => void deleteEstimate()}
|
||
>
|
||
{deleteTarget && <p className="text-sm text-slate-600">{deleteTarget.estimate_number} · {deleteTarget.title}</p>}
|
||
</ConfirmDialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Meta({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
|
||
<dd className="mt-1 break-words font-medium text-slate-950">{value}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|