diff --git a/README.md b/README.md index fb3a74b..8ccb7db 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,28 @@ Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffen - aktuellen Status - letzte Aktualisierung - kundenfreundliche Status-Timeline +- optionalen Kostenvoranschlag aus Olympus CRM v0.8.6 + +Wenn Olympus einen Kostenvoranschlag im Status `sent` liefert, zeigt die Website: + +- Nummer, Titel, Nachricht an den Kunden und Gültigkeit +- Positionen mit Menge, Einheit und Betrag +- Zwischensumme, Steuer und Gesamtbetrag +- Aktionen zum Freigeben, Ablehnen oder für eine Rückfrage + +Diese Aktionen laufen nicht direkt vom Browser zu Olympus. Der Browser ruft ausschließlich relative Website-Endpunkte auf: + +```text +POST /api/status//estimate/approve +POST /api/status//estimate/decline +POST /api/status//estimate/question +``` + +Die Website leitet diese Anfragen serverseitig an Olympus weiter: + +```text +${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status//estimate/ +``` Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen. diff --git a/app/api/status/[token]/estimate/action-response.ts b/app/api/status/[token]/estimate/action-response.ts new file mode 100644 index 0000000..27e9345 --- /dev/null +++ b/app/api/status/[token]/estimate/action-response.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import type { EstimateActionResult } from "@/lib/olympus/status"; + +export function estimateActionResponse(result: EstimateActionResult, successMessage: string) { + if (result.ok) { + return NextResponse.json({ message: successMessage }); + } + + if (result.reason === "invalid") { + return NextResponse.json( + { message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." }, + { status: 404 }, + ); + } + + if (result.reason === "unavailable") { + return NextResponse.json( + { message: "Der Reparaturstatus ist momentan nicht abrufbar." }, + { status: 502 }, + ); + } + + return NextResponse.json( + { message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." }, + { status: 400 }, + ); +} + +export async function readOptionalMessage(request: Request) { + try { + const body = await request.json() as { message?: unknown }; + return typeof body.message === "string" ? body.message : ""; + } catch { + return ""; + } +} diff --git a/app/api/status/[token]/estimate/approve/route.ts b/app/api/status/[token]/estimate/approve/route.ts new file mode 100644 index 0000000..a3ea9c8 --- /dev/null +++ b/app/api/status/[token]/estimate/approve/route.ts @@ -0,0 +1,15 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(_request: Request, { params }: RouteContext) { + const { token } = await params; + const result = await submitOlympusEstimateAction(token, "approve"); + + return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben."); +} diff --git a/app/api/status/[token]/estimate/decline/route.ts b/app/api/status/[token]/estimate/decline/route.ts new file mode 100644 index 0000000..0e18d1a --- /dev/null +++ b/app/api/status/[token]/estimate/decline/route.ts @@ -0,0 +1,16 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse, readOptionalMessage } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(request: Request, { params }: RouteContext) { + const { token } = await params; + const message = await readOptionalMessage(request); + const result = await submitOlympusEstimateAction(token, "decline", message); + + return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt."); +} diff --git a/app/api/status/[token]/estimate/question/route.ts b/app/api/status/[token]/estimate/question/route.ts new file mode 100644 index 0000000..4ff10f4 --- /dev/null +++ b/app/api/status/[token]/estimate/question/route.ts @@ -0,0 +1,16 @@ +import { submitOlympusEstimateAction } from "@/lib/olympus/status"; +import { estimateActionResponse, readOptionalMessage } from "../action-response"; + +type RouteContext = { + params: Promise<{ + token: string; + }>; +}; + +export async function POST(request: Request, { params }: RouteContext) { + const { token } = await params; + const message = await readOptionalMessage(request); + const result = await submitOlympusEstimateAction(token, "question", message); + + return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet."); +} diff --git a/app/globals.css b/app/globals.css index e8ef982..174bfdc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -638,6 +638,222 @@ h3 { font-size: 14px; } +.estimate-card { + display: grid; + gap: 22px; + border: 1px solid rgba(8, 42, 96, 0.12); + border-radius: var(--radius); + background: #f8fafc; + margin: 0 0 34px; + padding: 24px; +} + +.estimate-header, +.estimate-item, +.estimate-totals div, +.estimate-form-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.estimate-header h2 { + margin: 0 0 8px; + color: var(--ink); + font-size: clamp(26px, 3vw, 34px); +} + +.estimate-status { + display: inline-flex; + min-height: 32px; + align-items: center; + border: 1px solid rgba(232, 124, 46, 0.28); + border-radius: 999px; + background: rgba(232, 124, 46, 0.12); + color: #8b420d; + font-size: 13px; + font-weight: 800; + padding: 0 12px; + white-space: nowrap; +} + +.estimate-message, +.estimate-status-text { + border-left: 4px solid var(--steel); + background: #fff; + color: var(--ink); + margin: 0; + padding: 14px 16px; +} + +.estimate-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} + +.estimate-meta div { + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 14px; +} + +.estimate-meta dt, +.estimate-totals dt { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.estimate-meta dd, +.estimate-totals dd { + margin: 6px 0 0; + color: var(--ink); + font-weight: 800; +} + +.estimate-items { + display: grid; + gap: 12px; +} + +.estimate-item { + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 16px; +} + +.estimate-item h3 { + margin: 6px 0; + color: var(--ink); + font-size: 18px; +} + +.estimate-item p { + margin: 0; + color: var(--muted); +} + +.estimate-item-type { + color: var(--steel); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.estimate-item-price { + display: grid; + gap: 6px; + min-width: 150px; + text-align: right; +} + +.estimate-item-price span { + color: var(--muted); + font-size: 14px; +} + +.estimate-item-price strong { + color: var(--ink); + font-size: 18px; +} + +.estimate-totals { + display: grid; + gap: 8px; + width: min(380px, 100%); + justify-self: end; + margin: 0; +} + +.estimate-totals div { + border-bottom: 1px solid var(--line); + padding: 8px 0; +} + +.estimate-totals .estimate-total-highlight { + border-bottom: 0; + color: var(--ink); + font-size: 20px; +} + +.estimate-actions-panel { + display: grid; + gap: 16px; + border-top: 1px solid var(--line); + padding-top: 18px; +} + +.estimate-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.button.danger { + border-color: #a33a2d; + background: #a33a2d; + color: #fff; +} + +.button.danger:hover { + background: #842f25; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.64; +} + +.estimate-action-form { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 16px; +} + +.estimate-action-form label { + color: var(--ink); + font-weight: 800; +} + +.estimate-action-form textarea { + width: 100%; + border: 1px solid #cfd8e5; + border-radius: 8px; + color: var(--ink); + padding: 12px; + resize: vertical; +} + +.estimate-form-actions { + justify-content: flex-start; + flex-wrap: wrap; +} + +.estimate-feedback { + border: 1px solid rgba(46, 125, 50, 0.24); + border-radius: 8px; + background: rgba(46, 125, 50, 0.08); + color: #1b5e20; + font-weight: 800; + margin: 0; + padding: 12px 14px; +} + +.estimate-feedback.error { + border-color: rgba(163, 58, 45, 0.26); + background: rgba(163, 58, 45, 0.08); + color: #842f25; +} + .admin-login-page { min-height: 100vh; display: grid; @@ -1427,14 +1643,30 @@ h3 { } .status-title-row, - .timeline-content { + .timeline-content, + .estimate-header, + .estimate-item { display: grid; } - .status-badge { + .status-badge, + .estimate-status { width: fit-content; } + .estimate-meta { + grid-template-columns: 1fr; + } + + .estimate-item-price { + min-width: 0; + text-align: left; + } + + .estimate-totals { + justify-self: stretch; + } + .admin-sidebar { position: static; height: auto; diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx index 1962f85..00fdf7a 100644 --- a/app/status/[token]/page.tsx +++ b/app/status/[token]/page.tsx @@ -1,6 +1,11 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { fetchOlympusRepairStatus, type OlympusRepairStatus } from "@/lib/olympus/status"; +import { EstimateActions } from "@/components/status/EstimateActions"; +import { + fetchOlympusRepairStatus, + type OlympusEstimate, + type OlympusRepairStatus, +} from "@/lib/olympus/status"; type PageProps = { params: Promise<{ @@ -26,6 +31,130 @@ function formatDate(value: string) { }).format(new Date(value)); } +function formatDateOnly(value: string | null) { + if (!value) { + return "Nicht angegeben"; + } + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + }).format(new Date(value)); +} + +function formatMoney(cents: number, currency: string) { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: currency || "EUR", + }).format(cents / 100); +} + +function estimateStatusLabel(status: string) { + const labels: Record = { + draft: "In Vorbereitung", + sent: "Zur Freigabe", + approved: "Freigegeben", + declined: "Abgelehnt", + expired: "Abgelaufen", + cancelled: "Storniert", + }; + + return labels[status] ?? status; +} + +function estimateStatusText(status: string) { + const texts: Record = { + sent: "Bitte prüfen Sie den Kostenvoranschlag. Sie können ihn freigeben, ablehnen oder eine Rückfrage senden.", + approved: "Dieser Kostenvoranschlag wurde freigegeben.", + declined: "Dieser Kostenvoranschlag wurde abgelehnt.", + expired: "Dieser Kostenvoranschlag ist nicht mehr gültig.", + cancelled: "Dieser Kostenvoranschlag wurde storniert.", + }; + + return texts[status] ?? "Der Kostenvoranschlag ist aktuell nicht zur Bearbeitung freigegeben."; +} + +function estimateItemTypeLabel(type: string) { + const labels: Record = { + labor: "Arbeitszeit", + part: "Ersatzteil", + material: "Material", + service: "Service", + shipping: "Versand", + other: "Sonstiges", + }; + + return labels[type] ?? "Position"; +} + +function EstimateCard({ estimate, token }: { estimate: OlympusEstimate | null | undefined; token: string }) { + if (!estimate) { + return null; + } + + return ( +
+
+
+

Kostenvoranschlag

+

{estimate.title || "Kostenvoranschlag"}

+

Nummer {estimate.estimate_number}

+
+ {estimateStatusLabel(estimate.status)} +
+ + {estimate.customer_message &&

{estimate.customer_message}

} + +
+
+
Gültig bis
+
{formatDateOnly(estimate.valid_until)}
+
+
+
Status
+
{estimateStatusLabel(estimate.status)}
+
+
+ +
+ {estimate.items.map((item, index) => ( +
+
+ {estimateItemTypeLabel(item.item_type)} +

{item.title}

+ {item.description &&

{item.description}

} +
+
+ + {item.quantity} {item.unit} + + {formatMoney(item.total_cents, estimate.currency)} +
+
+ ))} +
+ +
+
+
Zwischensumme
+
{formatMoney(estimate.subtotal_cents, estimate.currency)}
+
+
+
Mehrwertsteuer
+
{formatMoney(estimate.tax_cents, estimate.currency)}
+
+
+
Gesamt
+
{formatMoney(estimate.total_cents, estimate.currency)}
+
+
+ +

{estimateStatusText(estimate.status)}

+ + {estimate.status === "sent" && } +
+ ); +} + function StatusError({ title, text }: { title: string; text: string }) { return (
@@ -126,6 +255,8 @@ export default async function RepairStatusPage({ params }: PageProps) { + +

Statusverlauf

diff --git a/components/status/EstimateActions.tsx b/components/status/EstimateActions.tsx new file mode 100644 index 0000000..feebf37 --- /dev/null +++ b/components/status/EstimateActions.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type EstimateAction = "approve" | "decline" | "question"; + +type EstimateActionsProps = { + token: string; +}; + +type ActionResponse = { + message?: string; +}; + +const actionLabels: Record = { + approve: "Freigeben", + decline: "Ablehnen", + question: "Rückfrage senden", +}; + +async function readActionMessage(response: Response) { + try { + const body = await response.json() as ActionResponse; + return body.message || "Die Aktion konnte nicht abgeschlossen werden."; + } catch { + return "Die Aktion konnte nicht abgeschlossen werden."; + } +} + +export function EstimateActions({ token }: EstimateActionsProps) { + const router = useRouter(); + const [activeForm, setActiveForm] = useState | null>(null); + const [message, setMessage] = useState(""); + const [pendingAction, setPendingAction] = useState(null); + const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + + async function submitAction(action: EstimateAction, actionMessage = "") { + setPendingAction(action); + setFeedback(null); + + try { + const response = await fetch(`/api/status/${encodeURIComponent(token)}/estimate/${action}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: actionMessage.trim() || null }), + }); + const responseMessage = await readActionMessage(response); + + if (!response.ok) { + setFeedback({ type: "error", text: responseMessage }); + return; + } + + setMessage(""); + setActiveForm(null); + setFeedback({ type: "success", text: responseMessage }); + router.refresh(); + } catch { + setFeedback({ + type: "error", + text: "Die Aktion konnte momentan nicht gesendet werden. Bitte versuchen Sie es erneut.", + }); + } finally { + setPendingAction(null); + } + } + + const isPending = pendingAction !== null; + + return ( +
+
+ + + +
+ + {activeForm && ( +
{ + event.preventDefault(); + void submitAction(activeForm, message); + }} + > + +