feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 00:57:02 +02:00
parent 6e7e75f864
commit 4436fe5f73
25 changed files with 1802 additions and 9 deletions

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}/cancel`);
}

View file

@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`);
}

View file

@ -0,0 +1,37 @@
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 }>;
};
async function proxyEstimateRequest(request: NextRequest, { params }: Params) {
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyEstimateRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyEstimateRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyEstimateRequest(request, context);
}

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}/send`);
}

View file

@ -0,0 +1,24 @@
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 }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
}
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates`);
}

View file

@ -7,6 +7,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr
import DetailSection from "@/components/common/DetailSection";
import { useToast } from "@/components/common/ToastProvider";
import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection";
import RepairEstimatesSection from "@/components/repairs/RepairEstimatesSection";
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
@ -169,6 +170,11 @@ export default function RepairDetailPage({ params }: Params) {
const canUpdate = hasPermission(currentUser, "repairs.update");
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
const canReadEstimates = hasPermission(currentUser, "repair_estimates.read");
const canCreateEstimates = hasPermission(currentUser, "repair_estimates.create");
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
async function createPublicLink() {
if (!repair || !canManagePublicLink) return;
@ -427,6 +433,18 @@ export default function RepairDetailPage({ params }: Params) {
})()}
</DetailSection>
<DetailSection title="Kostenvoranschläge">
<RepairEstimatesSection
repairId={repair.id}
customerEmail={repair.customer_email}
canRead={canReadEstimates}
canCreate={canCreateEstimates}
canUpdate={canUpdateEstimates}
canDelete={canDeleteEstimates}
canSend={canSendEstimates}
/>
</DetailSection>
<DetailSection title="Dokumente & Bilder">
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
</DetailSection>

View file

@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { FileCheck2, Plus, Send, Trash2, 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 {
RepairEstimate,
RepairEstimateItemPayload,
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",
};
function getErrorMessage(error: unknown) {
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 ?? "Aktion konnte nicht abgeschlossen werden";
}
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));
}
function emptyItem(): RepairEstimateItemPayload {
return {
item_type: "labor",
title: "",
description: "",
quantity: "1.00",
unit: "Std.",
unit_price_cents: 0,
};
}
function emptyPayload(): RepairEstimatePayload {
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;
};
export default function RepairEstimatesSection({
repairId,
customerEmail,
canRead,
canCreate,
canUpdate,
canDelete,
canSend,
}: 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<RepairEstimatePayload>(emptyPayload());
const [saving, setSaving] = useState(false);
const [pendingId, setPendingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
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 clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
const quantity = Number(item.quantity.replace(",", ".")) || 0;
return sum + Math.round(quantity * item.unit_price_cents);
}, 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,
title: item.title,
description: item.description ?? "",
quantity: String(item.quantity),
unit: item.unit,
unit_price_cents: item.unit_price_cents,
})),
});
setDialogOpen(true);
}
function updateItem(index: number, update: Partial<RepairEstimateItemPayload>) {
setPayload((current) => ({
...current,
items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item),
}));
}
async function saveEstimate() {
setSaving(true);
try {
const normalizedPayload = {
...payload,
internal_note: payload.internal_note || null,
valid_until: payload.valid_until || null,
items: payload.items.map((item) => ({
...item,
description: item.description || null,
quantity: item.quantity.replace(",", "."),
})),
};
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(estimate: RepairEstimate) {
setPendingId(estimate.id);
try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/cancel`);
await loadEstimates();
showToast({ type: "success", title: "Kostenvoranschlag storniert" });
} catch (err) {
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
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>
<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">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</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 && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => void cancelEstimate(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</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>
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
</div>
{payload.items.map((item, index) => (
<div key={index} className="rounded-lg border bg-slate-50 p-3">
<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} 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} 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} onChange={(event) => updateItem(index, { unit: event.target.value })} />
<Input type="number" min={0} step={1} placeholder="Cent" value={item.unit_price_cents} onChange={(event) => updateItem(index, { unit_price_cents: Number(event.target.value) || 0 })} />
<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>
<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 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>
);
}

View file

@ -23,6 +23,8 @@ export type RepairDocumentType =
| "shipping"
| "other";
export type RepairDocumentVisibility = "internal" | "customer";
export type RepairEstimateStatus = "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled";
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
export interface Repair {
id: number;
@ -161,3 +163,55 @@ export interface RepairDocument {
created_at: string;
updated_at: string;
}
export interface RepairEstimateItemPayload {
item_type: RepairEstimateItemType;
title: string;
description: string | null;
quantity: string;
unit: string;
unit_price_cents: number;
}
export interface RepairEstimatePayload {
title: string;
customer_message: string;
internal_note: string | null;
tax_rate_percent: string;
currency: string;
valid_until: string | null;
items: RepairEstimateItemPayload[];
}
export interface RepairEstimateItem extends RepairEstimateItemPayload {
id: number;
estimate_id: number;
position: number;
total_cents: number;
created_at: string;
updated_at: string;
}
export interface RepairEstimate {
id: number;
repair_id: number;
estimate_number: string;
status: RepairEstimateStatus;
title: string;
customer_message: string;
internal_note: string | null;
subtotal_cents: number;
tax_rate_percent: string;
tax_cents: number;
total_cents: number;
currency: string;
valid_until: string | null;
sent_at: string | null;
approved_at: string | null;
declined_at: string | null;
customer_response_message: string | null;
created_by_user_id: number | null;
created_at: string;
updated_at: string;
items: RepairEstimateItem[];
}