feat(repairs): add repair management foundation
This commit is contained in:
parent
22e54f212c
commit
e9ec207617
37 changed files with 2149 additions and 7 deletions
14
frontend/athena/app/api/repairs/[id]/history/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/history/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/history`);
|
||||
}
|
||||
39
frontend/athena/app/api/repairs/[id]/route.ts
Normal file
39
frontend/athena/app/api/repairs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyRepairRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyRepairRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyRepairRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyRepairRequest(request, context);
|
||||
}
|
||||
21
frontend/athena/app/api/repairs/[id]/status/route.ts
Normal file
21
frontend/athena/app/api/repairs/[id]/status/route.ts
Normal 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 PUT(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/status`);
|
||||
}
|
||||
18
frontend/athena/app/api/repairs/route.ts
Normal file
18
frontend/athena/app/api/repairs/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/repairs${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/repairs");
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export default function DashboardPage() {
|
|||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||
}
|
||||
|
||||
const cards = [...summary.customers, ...summary.users, ...summary.roles];
|
||||
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
234
frontend/athena/app/repairs/[id]/page.tsx
Normal file
234
frontend/athena/app/repairs/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
283
frontend/athena/app/repairs/page.tsx
Normal file
283
frontend/athena/app/repairs/page.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Edit, Eye, Plus, Trash2, Wrench } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import EmptyState from "@/components/common/EmptyState";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
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, RepairListResponse, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
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) {
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function deviceLabel(repair: Repair) {
|
||||
return `${repair.device_manufacturer} ${repair.device_model}`.trim();
|
||||
}
|
||||
|
||||
export default function RepairsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [repairs, setRepairs] = useState<Repair[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [statusError, setStatusError] = useState("");
|
||||
const [cancelError, setCancelError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<RepairStatus | "all">("all");
|
||||
const [sourceFilter, setSourceFilter] = useState<RepairSource | "all">("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<RepairPriority | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState("created_at");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [selectedRepair, setSelectedRepair] = useState<Repair | null>(null);
|
||||
const [cancelRepair, setCancelRepair] = useState<Repair | null>(null);
|
||||
|
||||
const loadRepairs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(pageSize));
|
||||
params.set("offset", String((page - 1) * pageSize));
|
||||
if (search.trim()) params.set("q", search.trim());
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
if (sourceFilter !== "all") params.set("source", sourceFilter);
|
||||
if (priorityFilter !== "all") params.set("priority", priorityFilter);
|
||||
const [repairResponse, meResponse] = await Promise.all([
|
||||
api.get<RepairListResponse>(`/repairs?${params.toString()}`),
|
||||
api.get<CurrentUser>("/me"),
|
||||
]);
|
||||
setRepairs(repairResponse.data.items);
|
||||
setTotal(repairResponse.data.total);
|
||||
setCurrentUser(meResponse.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, priorityFilter, search, sourceFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadRepairs();
|
||||
});
|
||||
}, [loadRepairs]);
|
||||
|
||||
const sortedRepairs = useMemo(() => [...repairs].sort((first, second) => {
|
||||
const firstValue = String(first[sortKey as keyof Repair] ?? "").toLowerCase();
|
||||
const secondValue = String(second[sortKey as keyof Repair] ?? "").toLowerCase();
|
||||
if (firstValue === secondValue) return 0;
|
||||
const result = firstValue > secondValue ? 1 : -1;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
}), [repairs, sortDirection, sortKey]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, pageCount);
|
||||
const canCreate = hasPermission(currentUser, "repairs.create");
|
||||
const canUpdate = hasPermission(currentUser, "repairs.update");
|
||||
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
||||
const canDelete = hasPermission(currentUser, "repairs.delete");
|
||||
|
||||
const columns: DataTableColumn<Repair>[] = [
|
||||
{ key: "repair_number", label: "Reparaturnummer", sortable: true, render: (repair) => <span className="font-medium text-slate-950">{repair.repair_number}</span> },
|
||||
{ key: "status", label: "Status", sortable: true, render: (repair) => <RepairStatusBadge status={repair.status} /> },
|
||||
{ key: "priority", label: "Priorität", sortable: true, render: (repair) => <RepairPriorityBadge priority={repair.priority} /> },
|
||||
{ key: "customer_name", label: "Kunde", sortable: true, render: (repair) => repair.customer_name },
|
||||
{ key: "device_model", label: "Gerät", sortable: true, render: (repair) => deviceLabel(repair) },
|
||||
{ key: "fault_description", label: "Fehler", render: (repair) => <span title={repair.fault_description}>{repair.fault_description.slice(0, 80)}{repair.fault_description.length > 80 ? "..." : ""}</span> },
|
||||
{ key: "source", label: "Quelle", sortable: true, render: (repair) => repair.source },
|
||||
{ key: "created_at", label: "Erstellt", sortable: true, render: (repair) => formatDate(repair.created_at) },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Aktionen",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (repair) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/repairs/${repair.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedRepair(repair); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
|
||||
{canUpdateStatus && <Button type="button" variant="ghost" size="icon-sm" title="Status ändern" onClick={() => { setSelectedRepair(repair); setStatusError(""); setStatusOpen(true); }}><Wrench size={16} /></Button>}
|
||||
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Stornieren" onClick={() => setCancelRepair(repair)}><Trash2 size={16} /></Button>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
|
||||
async function saveRepair(payload: RepairPayload) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selectedRepair) {
|
||||
const response = await api.put<Repair>(`/repairs/${selectedRepair.id}`, payload);
|
||||
setRepairs((current) => current.map((repair) => repair.id === selectedRepair.id ? response.data : repair));
|
||||
showToast({ type: "success", title: "Reparatur aktualisiert", description: response.data.repair_number });
|
||||
} else {
|
||||
const response = await api.post<Repair>("/repairs", payload);
|
||||
setRepairs((current) => [response.data, ...current].slice(0, pageSize));
|
||||
setTotal((current) => current + 1);
|
||||
showToast({ type: "success", title: "Reparatur angelegt", description: response.data.repair_number });
|
||||
}
|
||||
setFormOpen(false);
|
||||
} 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 (!selectedRepair) return;
|
||||
setPending(true);
|
||||
setStatusError("");
|
||||
try {
|
||||
const response = await api.put<Repair>(`/repairs/${selectedRepair.id}/status`, { status, note });
|
||||
setRepairs((current) => current.map((repair) => repair.id === selectedRepair.id ? response.data : repair));
|
||||
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
|
||||
setStatusOpen(false);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setStatusError(message);
|
||||
showToast({ type: "error", title: "Status konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancelRepair) return;
|
||||
setPending(true);
|
||||
setCancelError("");
|
||||
try {
|
||||
const response = await api.delete<Repair>(`/repairs/${cancelRepair.id}`);
|
||||
setRepairs((current) => current.map((repair) => repair.id === cancelRepair.id ? response.data : repair));
|
||||
showToast({ type: "success", title: "Reparatur storniert", description: cancelRepair.repair_number });
|
||||
setCancelRepair(null);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setCancelError(message);
|
||||
showToast({ type: "error", title: "Reparatur konnte nicht storniert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Reparaturen</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{total} Werkstattvorgänge</p>
|
||||
</div>
|
||||
{canCreate && <Button type="button" onClick={() => { setSelectedRepair(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Reparatur anlegen</Button>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
|
||||
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" />
|
||||
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as RepairStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Status</option>
|
||||
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
<select value={sourceFilter} onChange={(event) => { setSourceFilter(event.target.value as RepairSource | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Quellen</option>
|
||||
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}
|
||||
</select>
|
||||
<select value={priorityFilter} onChange={(event) => { setPriorityFilter(event.target.value as RepairPriority | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Prioritäten</option>
|
||||
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!loading && !error && total === 0 && !search && statusFilter === "all" && sourceFilter === "all" && priorityFilter === "all" ? (
|
||||
<EmptyState
|
||||
title="Noch keine Reparaturen vorhanden."
|
||||
description="Lege den ersten Werkstattvorgang manuell an oder nutze später die Website-Intake-Schnittstelle."
|
||||
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Reparatur anlegen</Button> : undefined}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sortedRepairs}
|
||||
rowKey={(repair) => repair.id}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="Noch keine Reparaturen vorhanden."
|
||||
emptyDescription="Passe Suche oder Filter an oder lege eine Reparatur an."
|
||||
onSort={handleSort}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Seite {currentPage} von {pageCount}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RepairFormDialog key={`form-${selectedRepair?.id ?? "new"}-${formOpen}`} open={formOpen} repair={selectedRepair} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveRepair} />
|
||||
<RepairStatusDialog key={`status-${selectedRepair?.id ?? "none"}-${statusOpen}`} open={statusOpen} repair={selectedRepair} pending={pending} serverError={statusError} onOpenChange={setStatusOpen} onSubmit={saveStatus} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(cancelRepair)}
|
||||
title="Reparatur stornieren"
|
||||
description="Die Reparatur wird nicht physisch gelöscht, sondern auf storniert gesetzt."
|
||||
confirmLabel="Stornieren"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCancelRepair(null);
|
||||
setCancelError("");
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmCancel}
|
||||
>
|
||||
{cancelRepair && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium">{cancelRepair.repair_number}</p>
|
||||
<p>{cancelRepair.customer_name} · {deviceLabel(cancelRepair)}</p>
|
||||
{cancelError && <p className="text-red-600">{cancelError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue