234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { ArrowLeft, Edit, Wrench } 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 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";
|
|
|
|
type Params = {
|
|
params: Promise<{
|
|
id: string;
|
|
}>;
|
|
};
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
|
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
return "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
function formatDate(value?: string | null) {
|
|
if (!value) return "-";
|
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
|
}
|
|
|
|
function DetailItem({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
|
|
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{typeof value === "boolean" ? (value ? "Ja" : "Nein") : value || "-"}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [formError, setFormError] = useState("");
|
|
const [statusError, setStatusError] = useState("");
|
|
|
|
useEffect(() => {
|
|
params.then((resolved) => setRepairId(resolved.id));
|
|
}, [params]);
|
|
|
|
const loadRepair = useCallback(async () => {
|
|
if (!repairId) return;
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [repairResponse, historyResponse, meResponse] = await Promise.all([
|
|
api.get<Repair>(`/repairs/${repairId}`),
|
|
api.get<RepairStatusHistory[]>(`/repairs/${repairId}/history`),
|
|
api.get<CurrentUser>("/me"),
|
|
]);
|
|
setRepair(repairResponse.data);
|
|
setHistory(historyResponse.data);
|
|
setCurrentUser(meResponse.data);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [repairId]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadRepair();
|
|
});
|
|
}, [loadRepair]);
|
|
|
|
async function saveRepair(payload: RepairPayload) {
|
|
if (!repair) return;
|
|
setPending(true);
|
|
setFormError("");
|
|
try {
|
|
const response = await api.put<Repair>(`/repairs/${repair.id}`, payload);
|
|
setRepair(response.data);
|
|
setFormOpen(false);
|
|
showToast({ type: "success", title: "Reparatur aktualisiert", description: response.data.repair_number });
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setFormError(message);
|
|
showToast({ type: "error", title: "Reparatur konnte nicht gespeichert werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function saveStatus(status: RepairStatus, note: string) {
|
|
if (!repair) return;
|
|
setPending(true);
|
|
setStatusError("");
|
|
try {
|
|
const response = await api.put<Repair>(`/repairs/${repair.id}/status`, { status, note });
|
|
const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`);
|
|
setRepair(response.data);
|
|
setHistory(historyResponse.data);
|
|
setStatusOpen(false);
|
|
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setStatusError(message);
|
|
showToast({ type: "error", title: "Status konnte nicht gespeichert werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Reparatur wird geladen...</div>;
|
|
}
|
|
|
|
if (error || !repair) {
|
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Reparatur nicht gefunden"}</div>;
|
|
}
|
|
|
|
const canUpdate = hasPermission(currentUser, "repairs.update");
|
|
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<Link href="/repairs" className="mb-3 inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-950"><ArrowLeft size={16} />Zurück</Link>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<h1 className="text-3xl font-bold text-slate-950">{repair.repair_number}</h1>
|
|
<RepairStatusBadge status={repair.status} />
|
|
<RepairPriorityBadge priority={repair.priority} />
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">{repair.customer_name} · {repair.device_manufacturer} {repair.device_model}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{canUpdate && <Button type="button" variant="outline" onClick={() => setFormOpen(true)}><Edit size={16} />Bearbeiten</Button>}
|
|
{canUpdateStatus && <Button type="button" onClick={() => setStatusOpen(true)}><Wrench size={16} />Status ändern</Button>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<DetailSection title="Kundendaten">
|
|
<dl className="grid gap-4 md:grid-cols-2">
|
|
<DetailItem label="Name" value={repair.customer_name} />
|
|
<DetailItem label="E-Mail" value={repair.customer_email} />
|
|
<DetailItem label="Telefon" value={repair.customer_phone} />
|
|
<DetailItem label="Kunden-ID" value={repair.customer_id} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Gerätedaten">
|
|
<dl className="grid gap-4 md:grid-cols-2">
|
|
<DetailItem label="Hersteller" value={repair.device_manufacturer} />
|
|
<DetailItem label="Modell" value={repair.device_model} />
|
|
<DetailItem label="Seriennummer" value={repair.device_serial_number} />
|
|
<DetailItem label="Gerätetyp" value={repair.device_type} />
|
|
<DetailItem label="Gerät geöffnet" value={repair.device_opened} />
|
|
</dl>
|
|
</DetailSection>
|
|
</div>
|
|
|
|
<DetailSection title="Fehler und Annahme">
|
|
<dl className="grid gap-4">
|
|
<DetailItem label="Fehlerbeschreibung" value={repair.fault_description} />
|
|
<DetailItem label="Zubehör" value={repair.accessories} />
|
|
<DetailItem label="Vorarbeiten" value={repair.previous_work} />
|
|
<DetailItem label="Annahmenotizen" value={repair.intake_notes} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Werkstatt">
|
|
<dl className="grid gap-4 md:grid-cols-2">
|
|
<DetailItem label="Diagnose" value={repair.diagnosis_notes} />
|
|
<DetailItem label="Reparaturnotizen" value={repair.repair_notes} />
|
|
<DetailItem label="Kostenschätzung" value={repair.estimate_notes} />
|
|
<DetailItem label="Geschätzte Kosten" value={repair.estimated_cost_cents !== null ? `${(repair.estimated_cost_cents / 100).toFixed(2)} €` : ""} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Metadaten">
|
|
<dl className="grid gap-4 md:grid-cols-3">
|
|
<DetailItem label="Quelle" value={repair.source} />
|
|
<DetailItem label="Quellreferenz" value={repair.source_reference} />
|
|
<DetailItem label="Erstellt" value={formatDate(repair.created_at)} />
|
|
<DetailItem label="Aktualisiert" value={formatDate(repair.updated_at)} />
|
|
<DetailItem label="Freigegeben" value={formatDate(repair.approved_at)} />
|
|
<DetailItem label="Abgeschlossen" value={formatDate(repair.completed_at)} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Statushistorie">
|
|
{history.length === 0 ? (
|
|
<p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p>
|
|
) : (
|
|
<div className="divide-y">
|
|
{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>
|
|
<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="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>
|
|
</DetailSection>
|
|
|
|
<RepairFormDialog key={`form-${repair.id}-${formOpen}`} open={formOpen} repair={repair} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveRepair} />
|
|
<RepairStatusDialog key={`status-${repair.id}-${statusOpen}`} open={statusOpen} repair={repair} pending={pending} serverError={statusError} onOpenChange={setStatusOpen} onSubmit={saveStatus} />
|
|
</div>
|
|
);
|
|
}
|