From efdc4d5be10e921f15eaa6a8de7547e0d8893dc4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 21:06:43 +0200 Subject: [PATCH 1/2] feat(status): add public repair status page --- .env.example | 10 +++ README.md | 40 ++++++++- app/globals.css | 170 +++++++++++++++++++++++++++++++++++- app/status/[token]/page.tsx | 137 +++++++++++++++++++++++++++++ docker-compose.yml | 2 + lib/olympus/status.ts | 70 +++++++++++++++ 6 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 app/status/[token]/page.tsx create mode 100644 lib/olympus/status.ts diff --git a/.env.example b/.env.example index e020556..9046cd5 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,13 @@ OLYMPUS_INTAKE_API_URL= # Optional future server-side Olympus intake token. # Leave empty until the Olympus integration is explicitly implemented. OLYMPUS_INTAKE_API_TOKEN= + +# Server-side Olympus base URL for public repair status links. +# Docker example when Hermes is reachable in the same network: http://hermes:8000 +# Production example: https://olympus.example.internal +# This URL must be reachable by the Next.js server, not by the browser. +OLYMPUS_PUBLIC_STATUS_API_URL= + +# Optional server-side token for future protected status API access. +# Leave empty for Olympus v0.8.1 unless the endpoint is protected later. +OLYMPUS_PUBLIC_STATUS_API_TOKEN= diff --git a/README.md b/README.md index bdea9cb..fb3a74b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Version: `0.4.1` - `/reparatur` - `/ueber-uns` - `/kontakt` +- `/status/[token]` - `/impressum` - `/datenschutz` - `/admin/login` @@ -109,8 +110,12 @@ Variablen: - `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung +- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks +- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe -Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. +Die Intake-Variablen sind für eine spätere serverseitige Integration vorbereitet. Die Status-Variablen werden für `/status/[token]` serverseitig verwendet. Sie werden nicht im Browser verwendet. + +Für Reparaturstatuslinks muss `OLYMPUS_PUBLIC_STATUS_API_URL` vom Next.js-Server erreichbar sein. In Docker kann das je nach Netzwerk z. B. `http://hermes:8000` sein. Produktiv sollte eine interne Server-zu-Server-Adresse oder eine abgesicherte Olympus-URL verwendet werden. Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein. @@ -130,6 +135,35 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `GET /api/media/[filename]` - `GET /api/health` +## Öffentlicher Reparaturstatus + +Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit: + +```text +/status/ +``` + +Die Seite ruft Olympus ausschließlich serverseitig auf: + +```text +${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/ +``` + +Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffentliche Statusseite zeigt nur: + +- Reparaturnummer +- Gerät +- aktuellen Status +- letzte Aktualisierung +- kundenfreundliche Status-Timeline + +Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen. + +Fehlerverhalten: + +- ungültiger oder abgelaufener Token: freundliche Meldung mit Links zu Reparatur und Kontakt +- Olympus nicht erreichbar: neutrale Meldung ohne technische Details + Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert. ## Admin-Bereich @@ -204,9 +238,9 @@ Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue ## Roadmap Kundenportal -Ein Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Für spätere Ausbaustufen sind folgende Routen vorgesehen: +Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen: -- `/status`: öffentliche Status-Einstiegsseite +- `/status/[token]`: öffentliche Statuslink-Seite für Olympus - `/reparatur/status`: Statusabfrage für Reparaturanfragen - `/portal/login`: geschützter Kundenlogin diff --git a/app/globals.css b/app/globals.css index 1405840..e8ef982 100644 --- a/app/globals.css +++ b/app/globals.css @@ -488,6 +488,156 @@ h3 { line-height: 1; } +.status-page { + min-height: calc(100vh - 78px); + padding: 72px 0; + background: + linear-gradient(135deg, rgba(244, 246, 248, 0.96), rgba(232, 238, 246, 0.94)), + url("/workbench-signal.svg"); + background-position: center; + background-size: cover; +} + +.status-page-inner { + display: grid; + align-items: start; +} + +.status-card { + border: 1px solid rgba(8, 42, 96, 0.12); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.97); + padding: 34px; + box-shadow: var(--shadow); +} + +.status-card-narrow { + width: min(760px, 100%); + margin: 0 auto; +} + +.status-card h1 { + margin-bottom: 10px; + color: var(--ink); + font-size: clamp(36px, 5vw, 58px); + line-height: 1; +} + +.status-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 26px; +} + +.status-badge, +.status-pill { + display: inline-flex; + min-height: 32px; + align-items: center; + border-radius: 999px; + border: 1px solid rgba(30, 159, 184, 0.24); + background: rgba(30, 159, 184, 0.1); + color: var(--navy); + font-size: 13px; + font-weight: 800; + padding: 0 12px; + white-space: nowrap; +} + +.status-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin: 0 0 34px; +} + +.status-summary div { + border: 1px solid var(--line); + border-radius: var(--radius); + background: #f8fafc; + padding: 16px; +} + +.status-summary dt { + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.status-summary dd { + margin: 0; + color: var(--ink); + font-size: 17px; + font-weight: 800; +} + +.status-timeline-section h2 { + margin-bottom: 20px; + font-size: clamp(26px, 3vw, 34px); +} + +.public-status-timeline { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.public-status-timeline li { + position: relative; + border-left: 2px solid #d7e0ea; + padding: 0 0 18px 26px; +} + +.public-status-timeline li:last-child { + padding-bottom: 0; +} + +.timeline-dot { + position: absolute; + left: -8px; + top: 15px; + width: 14px; + height: 14px; + border: 3px solid #fff; + border-radius: 999px; + background: var(--steel); + box-shadow: 0 0 0 1px rgba(8, 42, 96, 0.12); +} + +.public-status-timeline li.is-current .timeline-dot { + background: var(--orange); +} + +.timeline-content { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + padding: 16px; +} + +.timeline-title { + margin-bottom: 8px; + color: var(--ink); + font-size: 17px; + font-weight: 800; +} + +.timeline-content time, +.status-muted { + color: var(--muted); + font-size: 14px; +} + .admin-login-page { min-height: 100vh; display: grid; @@ -1263,10 +1413,28 @@ h3 { .admin-shell, .admin-kpis, .admin-detail-grid, - .media-grid { + .media-grid, + .status-summary { grid-template-columns: 1fr; } + .status-page { + padding: 38px 0; + } + + .status-card { + padding: 22px; + } + + .status-title-row, + .timeline-content { + display: grid; + } + + .status-badge { + width: fit-content; + } + .admin-sidebar { position: static; height: auto; diff --git a/app/status/[token]/page.tsx b/app/status/[token]/page.tsx new file mode 100644 index 0000000..1962f85 --- /dev/null +++ b/app/status/[token]/page.tsx @@ -0,0 +1,137 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { fetchOlympusRepairStatus, type OlympusRepairStatus } from "@/lib/olympus/status"; + +type PageProps = { + params: Promise<{ + token: string; + }>; +}; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Reparaturstatus | Funktechnik Schubert", + description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.", + robots: { + index: false, + follow: false, + }, +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function StatusError({ title, text }: { title: string; text: string }) { + return ( +
+
+
+

Reparaturstatus

+

{title}

+

{text}

+
+ Reparatur anfragen + Kontakt aufnehmen +
+
+
+
+ ); +} + +function StatusTimeline({ status }: { status: OlympusRepairStatus }) { + if (status.status_history_public.length === 0) { + return

Noch keine Statushistorie vorhanden.

; + } + + const latestIndex = status.status_history_public.length - 1; + + return ( +
    + {status.status_history_public.map((item, index) => ( +
  1. +
  2. + ))} +
+ ); +} + +export default async function RepairStatusPage({ params }: PageProps) { + const { token } = await params; + const result = await fetchOlympusRepairStatus(token); + + if (!result.ok && result.reason === "invalid") { + return ( + + ); + } + + if (!result.ok) { + return ( + + ); + } + + const status = result.status; + const device = `${status.device_manufacturer} ${status.device_model}`.trim(); + + return ( +
+
+
+

Reparaturstatus

+
+
+

Reparaturstatus

+

Hier sehen Sie den aktuellen Stand Ihrer Reparatur.

+
+ {status.public_status_label} +
+ +
+
+
Reparaturnummer
+
{status.repair_number}
+
+
+
Gerät
+
{device || "Nicht angegeben"}
+
+
+
Aktueller Status
+
{status.public_status_label}
+
+
+
Letzte Aktualisierung
+
{formatDate(status.updated_at)}
+
+
+ +
+

Statusverlauf

+ +
+
+
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index f96f7b6..9cc6fc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-} OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-} + OLYMPUS_PUBLIC_STATUS_API_URL: ${OLYMPUS_PUBLIC_STATUS_API_URL:-} + OLYMPUS_PUBLIC_STATUS_API_TOKEN: ${OLYMPUS_PUBLIC_STATUS_API_TOKEN:-} DOCKER_ENV: "true" volumes: - funktechnik-data:/app/data diff --git a/lib/olympus/status.ts b/lib/olympus/status.ts new file mode 100644 index 0000000..b0ac01e --- /dev/null +++ b/lib/olympus/status.ts @@ -0,0 +1,70 @@ +import "server-only"; + +export type OlympusRepairStatusHistoryItem = { + status: string; + status_label: string; + created_at: string; +}; + +export type OlympusRepairStatus = { + repair_number: string; + public_status_label: string; + device_manufacturer: string; + device_model: string; + status_history_public: OlympusRepairStatusHistoryItem[]; + updated_at: string; +}; + +export type RepairStatusResult = + | { ok: true; status: OlympusRepairStatus } + | { ok: false; reason: "invalid" | "unavailable" }; + +function getStatusApiBaseUrl() { + return process.env.OLYMPUS_PUBLIC_STATUS_API_URL?.replace(/\/$/, "") ?? ""; +} + +function getStatusApiToken() { + return process.env.OLYMPUS_PUBLIC_STATUS_API_TOKEN ?? ""; +} + +export async function fetchOlympusRepairStatus(token: string): Promise { + const baseUrl = getStatusApiBaseUrl(); + + if (!baseUrl || !token.trim()) { + return { ok: false, reason: "unavailable" }; + } + + const headers: HeadersInit = { + Accept: "application/json", + }; + const apiToken = getStatusApiToken(); + + if (apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + + let response: Response; + + try { + response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}`, { + headers, + cache: "no-store", + }); + } catch { + return { ok: false, reason: "unavailable" }; + } + + if (response.status === 401 || response.status === 403 || response.status === 404) { + return { ok: false, reason: "invalid" }; + } + + if (!response.ok) { + return { ok: false, reason: "unavailable" }; + } + + try { + return { ok: true, status: await response.json() as OlympusRepairStatus }; + } catch { + return { ok: false, reason: "unavailable" }; + } +} From 6f6891134e798ecd93917e5440e80b1e8ca5c316 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 02:17:19 +0200 Subject: [PATCH 2/2] feat(status): add estimate customer actions --- README.md | 22 ++ .../[token]/estimate/action-response.ts | 36 +++ .../status/[token]/estimate/approve/route.ts | 15 ++ .../status/[token]/estimate/decline/route.ts | 16 ++ .../status/[token]/estimate/question/route.ts | 16 ++ app/globals.css | 236 +++++++++++++++++- app/status/[token]/page.tsx | 133 +++++++++- components/status/EstimateActions.tsx | 153 ++++++++++++ lib/olympus/status.ts | 75 ++++++ 9 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 app/api/status/[token]/estimate/action-response.ts create mode 100644 app/api/status/[token]/estimate/approve/route.ts create mode 100644 app/api/status/[token]/estimate/decline/route.ts create mode 100644 app/api/status/[token]/estimate/question/route.ts create mode 100644 components/status/EstimateActions.tsx 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); + }} + > + +