diff --git a/README.md b/README.md index 8ccb7db..fb3a74b 100644 --- a/README.md +++ b/README.md @@ -156,28 +156,6 @@ 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 deleted file mode 100644 index 27e9345..0000000 --- a/app/api/status/[token]/estimate/action-response.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a3ea9c8..0000000 --- a/app/api/status/[token]/estimate/approve/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 0e18d1a..0000000 --- a/app/api/status/[token]/estimate/decline/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 4ff10f4..0000000 --- a/app/api/status/[token]/estimate/question/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 174bfdc..e8ef982 100644 --- a/app/globals.css +++ b/app/globals.css @@ -638,222 +638,6 @@ 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; @@ -1643,30 +1427,14 @@ h3 { } .status-title-row, - .timeline-content, - .estimate-header, - .estimate-item { + .timeline-content { display: grid; } - .status-badge, - .estimate-status { + .status-badge { 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 00fdf7a..1962f85 100644 --- a/app/status/[token]/page.tsx +++ b/app/status/[token]/page.tsx @@ -1,11 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { EstimateActions } from "@/components/status/EstimateActions"; -import { - fetchOlympusRepairStatus, - type OlympusEstimate, - type OlympusRepairStatus, -} from "@/lib/olympus/status"; +import { fetchOlympusRepairStatus, type OlympusRepairStatus } from "@/lib/olympus/status"; type PageProps = { params: Promise<{ @@ -31,130 +26,6 @@ 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 (
@@ -255,8 +126,6 @@ export default async function RepairStatusPage({ params }: PageProps) { - -

Statusverlauf

diff --git a/components/status/EstimateActions.tsx b/components/status/EstimateActions.tsx deleted file mode 100644 index feebf37..0000000 --- a/components/status/EstimateActions.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"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); - }} - > - -