feat(repairs): add status timeline and public link preparation
This commit is contained in:
parent
e9ec207617
commit
09cce1f2b6
22 changed files with 1027 additions and 28 deletions
|
|
@ -2,18 +2,26 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Edit, Wrench } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react";
|
||||
|
||||
import DetailSection from "@/components/common/DetailSection";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||
import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge";
|
||||
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser } from "@/types/rbac";
|
||||
import type { Repair, RepairPayload, RepairStatus, RepairStatusHistory } from "@/types/repair";
|
||||
import type {
|
||||
Repair,
|
||||
RepairNotificationOverview,
|
||||
RepairPayload,
|
||||
RepairPublicLink,
|
||||
RepairPublicLinkCreated,
|
||||
RepairStatus,
|
||||
RepairStatusHistory,
|
||||
} from "@/types/repair";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
|
|
@ -43,11 +51,19 @@ function DetailItem({ label, value }: { label: string; value?: string | number |
|
|||
);
|
||||
}
|
||||
|
||||
function historyLabel(item: RepairStatusHistory) {
|
||||
const oldStatus = item.old_status ? statusLabels[item.old_status as RepairStatus] ?? item.old_status : "Start";
|
||||
return `${oldStatus} → ${statusLabels[item.new_status]}`;
|
||||
}
|
||||
|
||||
export default function RepairDetailPage({ params }: Params) {
|
||||
const { showToast } = useToast();
|
||||
const [repairId, setRepairId] = useState("");
|
||||
const [repair, setRepair] = useState<Repair | null>(null);
|
||||
const [history, setHistory] = useState<RepairStatusHistory[]>([]);
|
||||
const [publicLink, setPublicLink] = useState<RepairPublicLink | null>(null);
|
||||
const [lastCreatedStatusPath, setLastCreatedStatusPath] = useState("");
|
||||
const [notifications, setNotifications] = useState<RepairNotificationOverview | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -56,6 +72,7 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [statusError, setStatusError] = useState("");
|
||||
const [communicationPending, setCommunicationPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
params.then((resolved) => setRepairId(resolved.id));
|
||||
|
|
@ -74,6 +91,15 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
setRepair(repairResponse.data);
|
||||
setHistory(historyResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repairId}/notifications`);
|
||||
setNotifications(notificationResponse.data);
|
||||
if (hasPermission(meResponse.data, "repairs.public_link.manage")) {
|
||||
const publicLinkResponse = await api.get<RepairPublicLink>(`/repairs/${repairId}/public-link`);
|
||||
setPublicLink(publicLinkResponse.data);
|
||||
} else {
|
||||
setPublicLink(null);
|
||||
}
|
||||
setLastCreatedStatusPath("");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -112,8 +138,10 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
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`);
|
||||
setRepair(response.data);
|
||||
setHistory(historyResponse.data);
|
||||
setNotifications(notificationResponse.data);
|
||||
setStatusOpen(false);
|
||||
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
|
||||
} catch (err) {
|
||||
|
|
@ -135,6 +163,53 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
|
||||
const canUpdate = hasPermission(currentUser, "repairs.update");
|
||||
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
||||
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
|
||||
|
||||
async function createPublicLink() {
|
||||
if (!repair || !canManagePublicLink) return;
|
||||
setCommunicationPending(true);
|
||||
try {
|
||||
const response = await api.post<RepairPublicLinkCreated>(`/repairs/${repair.id}/public-link`);
|
||||
setPublicLink(response.data);
|
||||
setLastCreatedStatusPath(response.data.public_status_path);
|
||||
showToast({ type: "success", title: "Statuslink erstellt", description: "Der Klartextlink wird nur jetzt angezeigt." });
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
showToast({ type: "error", title: "Statuslink konnte nicht erstellt werden", description: message });
|
||||
} finally {
|
||||
setCommunicationPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokePublicLink() {
|
||||
if (!repair || !canManagePublicLink) return;
|
||||
setCommunicationPending(true);
|
||||
try {
|
||||
const response = await api.delete<RepairPublicLink>(`/repairs/${repair.id}/public-link`);
|
||||
setPublicLink(response.data);
|
||||
setLastCreatedStatusPath("");
|
||||
showToast({ type: "success", title: "Statuslink deaktiviert", description: repair.repair_number });
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
showToast({ type: "error", title: "Statuslink konnte nicht deaktiviert 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." });
|
||||
return;
|
||||
}
|
||||
const absolutePath = new URL(lastCreatedStatusPath, window.location.origin).toString();
|
||||
try {
|
||||
await navigator.clipboard.writeText(absolutePath);
|
||||
showToast({ type: "success", title: "Link kopiert", description: lastCreatedStatusPath });
|
||||
} catch {
|
||||
showToast({ type: "error", title: "Link konnte nicht kopiert werden", description: "Bitte kopiere den angezeigten Link manuell." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -208,20 +283,90 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
{history.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
<div className="space-y-0">
|
||||
{history.map((item) => (
|
||||
<div key={item.id} className="py-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{item.old_status || "Start"} → {item.new_status}</p>
|
||||
<div key={item.id} className="relative border-l border-slate-200 pb-5 pl-6 last:pb-0">
|
||||
<span className={`absolute -left-2 top-1 h-4 w-4 rounded-full ring-4 ring-white ${item.new_status === repair.status ? "bg-blue-600" : "bg-slate-300"}`} />
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-slate-50 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium text-slate-950">{historyLabel(item)}</p>
|
||||
{item.new_status === repair.status && <span className="rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Aktuell</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{item.actor_display_name || item.actor_username || "System"}
|
||||
</p>
|
||||
{item.note && <p className="whitespace-pre-wrap text-sm text-slate-600">{item.note}</p>}
|
||||
</div>
|
||||
<time className="text-xs text-slate-500" dateTime={item.created_at}>{formatDate(item.created_at)}</time>
|
||||
</div>
|
||||
{item.note && <p className="mt-1 text-sm text-slate-500">{item.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Kundenkommunikation">
|
||||
<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">
|
||||
{publicLink?.is_active ? <CheckCircle2 className="mt-0.5 text-emerald-600" size={20} /> : <XCircle className="mt-0.5 text-slate-400" size={20} />}
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">Öffentlicher Statuslink {publicLink?.is_active ? "aktiv" : "inaktiv"}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Kunden erhalten später Statusupdates per E-Mail mit sicherem Link. Ein Kundenlogin ist dafür nicht erforderlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<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)} />
|
||||
</dl>
|
||||
{lastCreatedStatusPath && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
<p className="font-medium">Einmaliger Klartextlink</p>
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-950">
|
||||
<Mail size={16} />
|
||||
Vorbereitete Benachrichtigungen
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{notifications?.templates.map((template) => (
|
||||
<div key={template.event_type} className="rounded-lg border bg-white p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{template.subject}</p>
|
||||
{template.status && <RepairStatusBadge status={template.status} />}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-slate-500">{template.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{!notifications?.templates.length && <p className="text-sm text-slate-500">Noch keine Vorlagen vorbereitet.</p>}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
||||
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue