feat(repairs): add repair estimates
This commit is contained in:
parent
6e7e75f864
commit
4436fe5f73
25 changed files with 1802 additions and 9 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}/cancel`);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal file
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
401
frontend/athena/components/repairs/RepairEstimatesSection.tsx
Normal file
401
frontend/athena/components/repairs/RepairEstimatesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue