feat(repairs): add repair management foundation

This commit is contained in:
Schubert Ferenc 2026-07-04 11:46:58 +02:00
parent 22e54f212c
commit e9ec207617
37 changed files with 2149 additions and 7 deletions

View 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`);
}

View 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);
}

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 PUT(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/status`);
}

View 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");
}

View file

@ -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">

View 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>
);
}

View 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>
);
}

View file

@ -60,6 +60,7 @@ const menu = [
icon: Wrench,
name: "Reparaturen",
href: "/repairs",
permission: "repairs.read",
},
{
icon: Package,

View file

@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type { Customer } from "@/types/customer";
import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair";
type Props = {
open: boolean;
repair?: Repair | null;
pending: boolean;
serverError: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: RepairPayload) => void;
};
const initialPayload: RepairPayload = {
customer_id: null,
contact_id: null,
status: "new",
priority: "normal",
source: "manual",
source_reference: null,
customer_name: "",
customer_email: null,
customer_phone: "",
device_manufacturer: "",
device_model: "",
device_serial_number: null,
device_type: "",
accessories: "",
fault_description: "",
previous_work: "",
device_opened: false,
intake_notes: "",
diagnosis_notes: "",
repair_notes: "",
estimate_notes: "",
estimated_cost_cents: null,
};
function payloadFromRepair(repair?: Repair | null): RepairPayload {
if (!repair) {
return initialPayload;
}
return {
customer_id: repair.customer_id,
contact_id: repair.contact_id,
status: repair.status,
priority: repair.priority,
source: repair.source,
source_reference: repair.source_reference,
customer_name: repair.customer_name,
customer_email: repair.customer_email || null,
customer_phone: repair.customer_phone,
device_manufacturer: repair.device_manufacturer,
device_model: repair.device_model,
device_serial_number: repair.device_serial_number,
device_type: repair.device_type,
accessories: repair.accessories,
fault_description: repair.fault_description,
previous_work: repair.previous_work,
device_opened: repair.device_opened,
intake_notes: repair.intake_notes,
diagnosis_notes: repair.diagnosis_notes,
repair_notes: repair.repair_notes,
estimate_notes: repair.estimate_notes,
estimated_cost_cents: repair.estimated_cost_cents,
};
}
export default function RepairFormDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) {
const [payload, setPayload] = useState<RepairPayload>(payloadFromRepair(repair));
const [clientError, setClientError] = useState("");
const [customers, setCustomers] = useState<Customer[]>([]);
useEffect(() => {
if (!open) {
return;
}
let active = true;
async function loadCustomers() {
try {
const response = await api.get<Customer[]>("/customers");
if (active) {
setCustomers(response.data);
}
} catch {
if (active) {
setCustomers([]);
}
}
}
void loadCustomers();
return () => {
active = false;
};
}, [open]);
function update<K extends keyof RepairPayload>(key: K, value: RepairPayload[K]) {
setPayload((current) => ({ ...current, [key]: value }));
}
function submit() {
if (!payload.customer_name.trim() || !payload.device_manufacturer.trim() || !payload.device_model.trim() || !payload.fault_description.trim()) {
setClientError("Bitte Kundennamen, Hersteller, Modell und Fehlerbeschreibung ausfüllen.");
return;
}
setClientError("");
onSubmit(payload);
}
function selectCustomer(customerId: string) {
if (!customerId) {
update("customer_id", null);
return;
}
const customer = customers.find((item) => String(item.id) === customerId);
if (!customer) {
return;
}
setPayload((current) => ({
...current,
customer_id: customer.id,
customer_name: customer.company_name,
customer_email: customer.email || current.customer_email,
customer_phone: customer.phone || current.customer_phone,
}));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{repair ? "Reparatur bearbeiten" : "Reparatur anlegen"}</DialogTitle>
<DialogDescription>Erfasse Kundendaten, Gerätedaten und Werkstattnotizen.</DialogDescription>
</DialogHeader>
<div className="grid gap-5">
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Kunde</h3>
<label className="grid gap-1 text-sm">Bestehender Kunde
<select className="h-8 rounded-lg border px-2 text-sm" value={payload.customer_id ?? ""} onChange={(event) => selectCustomer(event.target.value)}>
<option value="">Manuelle Kundendaten</option>
{customers.map((customer) => <option key={customer.id} value={customer.id}>{customer.company_name}</option>)}
</select>
</label>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Name<Input value={payload.customer_name} onChange={(event) => update("customer_name", event.target.value)} /></label>
<label className="grid gap-1 text-sm">E-Mail<Input type="email" value={payload.customer_email ?? ""} onChange={(event) => update("customer_email", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Telefon<Input value={payload.customer_phone} onChange={(event) => update("customer_phone", event.target.value)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Gerät</h3>
<div className="grid gap-3 md:grid-cols-4">
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.device_manufacturer} onChange={(event) => update("device_manufacturer", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Modell<Input value={payload.device_model} onChange={(event) => update("device_model", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Seriennummer<Input value={payload.device_serial_number ?? ""} onChange={(event) => update("device_serial_number", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Gerätetyp<Input value={payload.device_type} onChange={(event) => update("device_type", event.target.value)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Vorgang</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Status<select className="h-8 rounded-lg border px-2 text-sm" value={payload.status} onChange={(event) => update("status", event.target.value as RepairStatus)}>
{["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></label>
<label className="grid gap-1 text-sm">Priorität<select className="h-8 rounded-lg border px-2 text-sm" value={payload.priority} onChange={(event) => update("priority", event.target.value as RepairPriority)}>
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Quelle<select className="h-8 rounded-lg border px-2 text-sm" value={payload.source} onChange={(event) => update("source", event.target.value as RepairSource)}>
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}
</select></label>
</div>
<label className="grid gap-1 text-sm">Quellreferenz<Input value={payload.source_reference ?? ""} onChange={(event) => update("source_reference", event.target.value || null)} /></label>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Beschreibung</h3>
<label className="grid gap-1 text-sm">Fehlerbeschreibung<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={payload.fault_description} onChange={(event) => update("fault_description", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Zubehör<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.accessories} onChange={(event) => update("accessories", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Vorarbeiten<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.previous_work} onChange={(event) => update("previous_work", event.target.value)} /></label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.device_opened} onChange={(event) => update("device_opened", event.target.checked)} /> Gerät wurde bereits geöffnet</label>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Werkstattnotizen</h3>
<label className="grid gap-1 text-sm">Annahme<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.intake_notes} onChange={(event) => update("intake_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Diagnose<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.diagnosis_notes} onChange={(event) => update("diagnosis_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Reparatur<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.repair_notes} onChange={(event) => update("repair_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Kostenschätzung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.estimate_notes} onChange={(event) => update("estimate_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Geschätzte Kosten in Cent<Input type="number" min={0} value={payload.estimated_cost_cents ?? ""} onChange={(event) => update("estimated_cost_cents", event.target.value ? Number(event.target.value) : null)} /></label>
</section>
</div>
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,59 @@
import type { RepairPriority, RepairStatus } from "@/types/repair";
const statusLabels: Record<RepairStatus, string> = {
new: "Neu",
accepted: "Angenommen",
diagnosis: "Diagnose",
estimate: "Kostenvoranschlag",
waiting_for_customer: "Wartet auf Kunde",
approved: "Freigegeben",
repair: "Reparatur",
final_test: "Endprüfung",
ready_for_pickup: "Abholbereit",
shipped: "Versendet",
completed: "Abgeschlossen",
cancelled: "Storniert",
};
const priorityLabels: Record<RepairPriority, string> = {
low: "Niedrig",
normal: "Normal",
high: "Hoch",
urgent: "Dringend",
};
const statusColors: Record<RepairStatus, string> = {
new: "bg-blue-50 text-blue-700 ring-blue-600/20",
accepted: "bg-sky-50 text-sky-700 ring-sky-600/20",
diagnosis: "bg-violet-50 text-violet-700 ring-violet-600/20",
estimate: "bg-amber-50 text-amber-700 ring-amber-600/20",
waiting_for_customer: "bg-orange-50 text-orange-700 ring-orange-600/20",
approved: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
repair: "bg-cyan-50 text-cyan-700 ring-cyan-600/20",
final_test: "bg-indigo-50 text-indigo-700 ring-indigo-600/20",
ready_for_pickup: "bg-lime-50 text-lime-700 ring-lime-600/20",
shipped: "bg-teal-50 text-teal-700 ring-teal-600/20",
completed: "bg-slate-100 text-slate-700 ring-slate-500/20",
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
};
const priorityColors: Record<RepairPriority, string> = {
low: "bg-slate-100 text-slate-700 ring-slate-500/20",
normal: "bg-blue-50 text-blue-700 ring-blue-600/20",
high: "bg-amber-50 text-amber-700 ring-amber-600/20",
urgent: "bg-red-50 text-red-700 ring-red-600/20",
};
function Badge({ label, color }: { label: string; color: string }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ring-1 ${color}`}>{label}</span>;
}
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
return <Badge label={statusLabels[status]} color={statusColors[status]} />;
}
export function RepairPriorityBadge({ priority }: { priority: RepairPriority }) {
return <Badge label={priorityLabels[priority]} color={priorityColors[priority]} />;
}
export { priorityLabels, statusLabels };

View file

@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { Repair, RepairStatus } from "@/types/repair";
type Props = {
open: boolean;
repair: Repair | null;
pending: boolean;
serverError: string;
onOpenChange: (open: boolean) => void;
onSubmit: (status: RepairStatus, note: string) => void;
};
const statuses: RepairStatus[] = ["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"];
export default function RepairStatusDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) {
const [status, setStatus] = useState<RepairStatus>("new");
const [note, setNote] = useState("");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Status ändern</DialogTitle>
<DialogDescription>{repair ? `${repair.repair_number} · ${repair.customer_name}` : "Reparaturstatus aktualisieren"}</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<label className="grid gap-1 text-sm">Neuer Status<select className="h-8 rounded-lg border px-2 text-sm" value={status} onChange={(event) => setStatus(event.target.value as RepairStatus)}>
{statuses.map((item) => <option key={item} value={item}>{item}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={() => onSubmit(status, note)} disabled={pending}>{pending ? "Speichert..." : "Status speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -22,5 +22,6 @@ export const config = {
"/users/:path*",
"/roles/:path*",
"/customers/:path*",
"/repairs/:path*",
],
};

View file

@ -15,6 +15,7 @@ export interface DashboardSummary {
latest_customers: Customer[];
users: MetricCard[];
roles: MetricCard[];
repairs: MetricCard[];
activities: EmptyWidget;
tasks: EmptyWidget;
tickets: EmptyWidget;

View file

@ -0,0 +1,89 @@
export type RepairStatus =
| "new"
| "accepted"
| "diagnosis"
| "estimate"
| "waiting_for_customer"
| "approved"
| "repair"
| "final_test"
| "ready_for_pickup"
| "shipped"
| "completed"
| "cancelled";
export type RepairPriority = "low" | "normal" | "high" | "urgent";
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
export interface Repair {
id: number;
repair_number: string;
customer_id: number | null;
contact_id: number | null;
status: RepairStatus;
priority: RepairPriority;
source: RepairSource;
source_reference: string | null;
customer_name: string;
customer_email: string;
customer_phone: string;
device_manufacturer: string;
device_model: string;
device_serial_number: string | null;
device_type: string;
accessories: string;
fault_description: string;
previous_work: string;
device_opened: boolean;
intake_notes: string;
diagnosis_notes: string;
repair_notes: string;
estimate_notes: string;
estimated_cost_cents: number | null;
approved_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface RepairPayload {
customer_id: number | null;
contact_id: number | null;
status: RepairStatus;
priority: RepairPriority;
source: RepairSource;
source_reference: string | null;
customer_name: string;
customer_email: string | null;
customer_phone: string;
device_manufacturer: string;
device_model: string;
device_serial_number: string | null;
device_type: string;
accessories: string;
fault_description: string;
previous_work: string;
device_opened: boolean;
intake_notes: string;
diagnosis_notes: string;
repair_notes: string;
estimate_notes: string;
estimated_cost_cents: number | null;
}
export interface RepairListResponse {
items: Repair[];
total: number;
limit: number;
offset: number;
}
export interface RepairStatusHistory {
id: number;
repair_id: number;
old_status: string | null;
new_status: RepairStatus;
note: string | null;
actor_user_id: number | null;
created_at: string;
}