feat(repairs): add repair management foundation
This commit is contained in:
parent
22e54f212c
commit
e9ec207617
37 changed files with 2149 additions and 7 deletions
|
|
@ -60,6 +60,7 @@ const menu = [
|
|||
icon: Wrench,
|
||||
name: "Reparaturen",
|
||||
href: "/repairs",
|
||||
permission: "repairs.read",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
|
|
|
|||
222
frontend/athena/components/repairs/RepairFormDialog.tsx
Normal file
222
frontend/athena/components/repairs/RepairFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/athena/components/repairs/RepairStatusBadge.tsx
Normal file
59
frontend/athena/components/repairs/RepairStatusBadge.tsx
Normal 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 };
|
||||
45
frontend/athena/components/repairs/RepairStatusDialog.tsx
Normal file
45
frontend/athena/components/repairs/RepairStatusDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue