feat(repairs): automatic repair status emails

This commit is contained in:
Schubert Ferenc 2026-07-04 22:58:47 +02:00
parent 3ed6596bf0
commit c58e212e3a
21 changed files with 525 additions and 36 deletions

View file

@ -0,0 +1,21 @@
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 POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/send-status-mail`);
}

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react";
import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wrench, XCircle } from "lucide-react";
import DetailSection from "@/components/common/DetailSection";
import { useToast } from "@/components/common/ToastProvider";
@ -137,11 +137,15 @@ export default function RepairDetailPage({ params }: Params) {
setStatusError("");
try {
const response = await api.put<Repair>(`/repairs/${repair.id}/status`, { status, note });
const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`);
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`);
const [historyResponse, notificationResponse, publicLinkResponse] = await Promise.all([
api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`),
api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`),
canManagePublicLink ? api.get<RepairPublicLink>(`/repairs/${repair.id}/public-link`) : Promise.resolve(null),
]);
setRepair(response.data);
setHistory(historyResponse.data);
setNotifications(notificationResponse.data);
if (publicLinkResponse) setPublicLink(publicLinkResponse.data);
setStatusOpen(false);
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
} catch (err) {
@ -197,6 +201,27 @@ export default function RepairDetailPage({ params }: Params) {
}
}
async function sendStatusMail() {
if (!repair || !canUpdate) return;
setCommunicationPending(true);
try {
await api.post(`/repairs/${repair.id}/send-status-mail`);
const [notificationResponse, publicLinkResponse] = await Promise.all([
api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`),
canManagePublicLink ? api.get<RepairPublicLink>(`/repairs/${repair.id}/public-link`) : Promise.resolve(null),
]);
setNotifications(notificationResponse.data);
if (publicLinkResponse) setPublicLink(publicLinkResponse.data);
setLastCreatedStatusPath("");
showToast({ type: "success", title: "Statusmail verarbeitet", description: "Der Versandversuch wurde dokumentiert." });
} catch (err) {
const message = getErrorMessage(err);
showToast({ type: "error", title: "Statusmail konnte nicht verarbeitet werden", description: message });
} finally {
setCommunicationPending(false);
}
}
async function copyPublicLink() {
if (!lastCreatedStatusPath) {
showToast({ type: "error", title: "Kein Klartextlink verfügbar", description: "Der sichere Link wird nur direkt nach der Erstellung angezeigt." });
@ -307,6 +332,9 @@ export default function RepairDetailPage({ params }: Params) {
</DetailSection>
<DetailSection title="Kundenkommunikation">
{(() => {
const lastMail = notifications?.events[0] ?? null;
return (
<div className="grid gap-4 xl:grid-cols-[1fr_1.3fr]">
<div className="space-y-4 rounded-lg border bg-slate-50 p-4">
<div className="flex items-start gap-3">
@ -322,6 +350,12 @@ export default function RepairDetailPage({ params }: Params) {
<DetailItem label="Token-Hinweis" value={publicLink?.token_hint ? `...${publicLink.token_hint}` : "-"} />
<DetailItem label="Zuletzt verwendet" value={formatDate(publicLink?.last_used_at)} />
<DetailItem label="Erstellt" value={formatDate(publicLink?.created_at)} />
<DetailItem label="Letzter Versand" value={formatDate(lastMail?.created_at)} />
<DetailItem label="Letzte Mail" value={lastMail?.subject} />
<DetailItem label="Empfänger" value={lastMail?.recipient} />
<DetailItem label="Mail-Status" value={lastMail?.status} />
<DetailItem label="Versand erfolgreich" value={lastMail ? lastMail.success : null} />
<DetailItem label="Fehlermeldung" value={lastMail?.error_message} />
</dl>
{lastCreatedStatusPath && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
@ -329,13 +363,17 @@ export default function RepairDetailPage({ params }: Params) {
<p className="mt-1 break-all">{lastCreatedStatusPath}</p>
</div>
)}
{canManagePublicLink ? (
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink erstellen</Button>
<div className="flex flex-wrap gap-2">
{canUpdate && <Button type="button" onClick={sendStatusMail} disabled={communicationPending || !repair.customer_email}><Send size={16} />E-Mail erneut senden</Button>}
{canManagePublicLink && (
<>
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink neu erzeugen</Button>
<Button type="button" variant="outline" onClick={copyPublicLink} disabled={!lastCreatedStatusPath || communicationPending}><Copy size={16} />Link kopieren</Button>
<Button type="button" variant="destructive" onClick={revokePublicLink} disabled={!publicLink?.is_active || communicationPending}>Statuslink deaktivieren</Button>
</div>
) : (
</>
)}
</div>
{!canManagePublicLink && (
<p className="text-sm text-slate-500">Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.</p>
)}
</div>
@ -343,7 +381,26 @@ export default function RepairDetailPage({ params }: Params) {
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-slate-950">
<Mail size={16} />
Vorbereitete Benachrichtigungen
Benachrichtigungen
</div>
<div className="rounded-lg border bg-white p-3">
<p className="font-medium text-slate-950">Versandhistorie</p>
{notifications?.events.length ? (
<div className="mt-3 grid gap-2">
{notifications.events.slice(0, 5).map((event) => (
<div key={event.id} className="rounded-lg border bg-slate-50 p-3 text-sm">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{event.subject}</p>
<span className={event.success ? "text-emerald-700" : "text-amber-700"}>{event.success ? "Erfolgreich" : event.status}</span>
</div>
<p className="mt-1 text-slate-500">{formatDate(event.created_at)} · {event.recipient || "Kein Empfänger"}</p>
{event.error_message && <p className="mt-1 text-red-600">{event.error_message}</p>}
</div>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-500">Noch kein Versandversuch dokumentiert.</p>
)}
</div>
<div className="grid gap-2">
{notifications?.templates.map((template) => (
@ -360,11 +417,13 @@ export default function RepairDetailPage({ params }: Params) {
<div className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">
<div className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 text-slate-500" size={16} />
<p>Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.</p>
<p>Statusmails werden beim Statuswechsel automatisch verarbeitet. Interne Notizen werden nicht öffentlich ausgegeben.</p>
</div>
</div>
</div>
</div>
);
})()}
</DetailSection>
<DetailSection title="Audit und Aktivität vorbereitet">

View file

@ -120,7 +120,9 @@ export interface RepairNotificationEvent {
channel: string;
recipient: string;
subject: string;
template: string;
status: "pending" | "sent" | "failed" | "skipped";
success: boolean;
error_message: string | null;
created_at: string;
sent_at: string | null;