feat(repairs): automatic repair status emails
This commit is contained in:
parent
3ed6596bf0
commit
c58e212e3a
21 changed files with 525 additions and 36 deletions
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue