377 lines
19 KiB
TypeScript
377 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { ArrowRight, Loader2, Search, Sparkles, UserRoundCheck } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useAuth } from "@/components/auth";
|
|
import { apiFetch, apiGet, Customer, Paginated, QuickStartCustomerData, ValidationItem } from "@/lib/api";
|
|
|
|
const validationTypeLabels: Record<string, string> = {
|
|
Erstvalidierung: "Erstvalidierung",
|
|
Revalidierung: "Revalidierung",
|
|
Leistungsbeurteilung: "Leistungsbeurteilung"
|
|
};
|
|
|
|
export default function QuickStartValidationPage() {
|
|
const { token, user } = useAuth();
|
|
const [search, setSearch] = useState("");
|
|
const [selectedCustomerId, setSelectedCustomerId] = useState("");
|
|
const [selectedLocationId, setSelectedLocationId] = useState("");
|
|
const [selectedContactId, setSelectedContactId] = useState("");
|
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
|
const [validationType, setValidationType] = useState("Erstvalidierung");
|
|
const [error, setError] = useState("");
|
|
|
|
const customers = useQuery({
|
|
queryKey: ["quickstart-customers", search, token],
|
|
queryFn: () => apiGet<Paginated<Customer>>(`/customers?page=1&page_size=12${search.trim() ? `&search=${encodeURIComponent(search.trim())}` : ""}`, token ?? ""),
|
|
enabled: Boolean(token)
|
|
});
|
|
const customerData = useQuery({
|
|
queryKey: ["quickstart-customer-data", selectedCustomerId, token],
|
|
queryFn: () => apiGet<QuickStartCustomerData>(`/customers/${selectedCustomerId}/quickstart-data`, token ?? ""),
|
|
enabled: Boolean(token && selectedCustomerId)
|
|
});
|
|
|
|
useEffect(() => {
|
|
setSelectedLocationId("");
|
|
setSelectedContactId("");
|
|
setSelectedDeviceId("");
|
|
setError("");
|
|
}, [selectedCustomerId]);
|
|
|
|
useEffect(() => {
|
|
const data = customerData.data;
|
|
if (!data) {
|
|
return;
|
|
}
|
|
setSelectedLocationId((current) => {
|
|
if (data.locations.length === 1) {
|
|
return data.locations[0].id;
|
|
}
|
|
return current && data.locations.some((item) => item.id === current) ? current : "";
|
|
});
|
|
setSelectedContactId((current) => {
|
|
if (data.contacts.length === 1) {
|
|
return data.contacts[0].id;
|
|
}
|
|
return current && data.contacts.some((item) => item.id === current) ? current : "";
|
|
});
|
|
setSelectedDeviceId((current) => {
|
|
if (data.devices.length === 1) {
|
|
return data.devices[0].id;
|
|
}
|
|
if (current && data.devices.some((item) => item.id === current)) {
|
|
return current;
|
|
}
|
|
if (validationType === "Revalidierung" || validationType === "Leistungsbeurteilung") {
|
|
const previousDevice = data.validations.find((validation) => data.devices.some((device) => device.id === validation.device_id))?.device_id;
|
|
return previousDevice ?? "";
|
|
}
|
|
return "";
|
|
});
|
|
}, [customerData.data, validationType]);
|
|
|
|
useEffect(() => {
|
|
const data = customerData.data;
|
|
if (!data || !selectedDeviceId || selectedLocationId) {
|
|
return;
|
|
}
|
|
const device = data.devices.find((item) => item.id === selectedDeviceId);
|
|
if (device?.location_id && data.locations.some((item) => item.id === device.location_id)) {
|
|
setSelectedLocationId(device.location_id);
|
|
}
|
|
}, [customerData.data, selectedDeviceId, selectedLocationId]);
|
|
|
|
const selectedCustomer = customers.data?.items.find((item) => item.id === selectedCustomerId) ?? customerData.data?.customer ?? null;
|
|
const selectedDevice = customerData.data?.devices.find((item) => item.id === selectedDeviceId) ?? null;
|
|
const currentExaminer = user ? `${user.first_name} ${user.last_name}` : "nicht angemeldet";
|
|
const locations = customerData.data?.locations ?? [];
|
|
const contacts = customerData.data?.contacts ?? [];
|
|
const devices = customerData.data?.devices ?? [];
|
|
const hasValidLocation = Boolean(selectedLocationId && locations.some((item) => item.id === selectedLocationId));
|
|
const hasValidContact = !selectedContactId || contacts.some((item) => item.id === selectedContactId);
|
|
const hasValidDevice = Boolean(selectedDeviceId && devices.some((item) => item.id === selectedDeviceId));
|
|
const latestValidation = useMemo(() => {
|
|
if (!selectedDeviceId) {
|
|
return null;
|
|
}
|
|
return customerData.data?.validations.find((item) => item.device_id === selectedDeviceId) ?? null;
|
|
}, [customerData.data?.validations, selectedDeviceId]);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: async () =>
|
|
apiFetch<ValidationItem>(
|
|
"/validations/quick-start",
|
|
token ?? "",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
customer_id: selectedCustomerId,
|
|
location_id: selectedLocationId || null,
|
|
contact_id: selectedContactId || null,
|
|
device_id: selectedDeviceId || null,
|
|
validation_type: validationType
|
|
})
|
|
}
|
|
),
|
|
onSuccess: (created) => {
|
|
window.location.replace(`/validations/${created.id}/edit`);
|
|
},
|
|
onError: (err: Error) => setError(err.message)
|
|
});
|
|
|
|
const customerCards = customers.data?.items ?? [];
|
|
const canCreate =
|
|
Boolean(selectedCustomerId) &&
|
|
Boolean(validationType) &&
|
|
Boolean(user) &&
|
|
hasValidDevice &&
|
|
hasValidLocation &&
|
|
hasValidContact &&
|
|
!createMutation.isPending;
|
|
const disabledReason = !selectedCustomerId
|
|
? "Bitte Kunde auswählen"
|
|
: !hasValidDevice
|
|
? "Bitte Gerät auswählen"
|
|
: locations.length === 0
|
|
? "Für den Kunden ist kein Standort vorhanden"
|
|
: !hasValidLocation
|
|
? "Bitte Standort auswählen"
|
|
: !hasValidContact
|
|
? "Bitte gültigen Ansprechpartner auswählen"
|
|
: !user
|
|
? "Bitte anmelden"
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
|
<div className="flex items-center gap-3 text-primary">
|
|
<Sparkles className="h-5 w-5" />
|
|
<p className="text-sm font-medium uppercase tracking-wide">Quickstart</p>
|
|
</div>
|
|
<h1 className="mt-3 text-3xl font-semibold text-text">Neue Validierung starten</h1>
|
|
<p className="mt-2 max-w-3xl text-text-light">
|
|
Wir fassen die wichtigsten Entscheidungen zusammen und öffnen danach direkt die bestehende Validierungsbearbeitung.
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-2 text-sm text-text-light">
|
|
<span className="rounded-full border border-border bg-background px-3 py-1">1 Kunde</span>
|
|
<span className="rounded-full border border-border bg-background px-3 py-1">2 Gerät</span>
|
|
<span className="rounded-full border border-border bg-background px-3 py-1">3 Validierungsart</span>
|
|
<span className="rounded-full border border-border bg-background px-3 py-1">4 Erstellen</span>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[1.1fr_1fr]">
|
|
<div className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-xl font-semibold">1. Kunde</h2>
|
|
{selectedCustomer && <span className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">Ausgewählt</span>}
|
|
</div>
|
|
{customers.isError && <p className="mt-3 rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Kunden konnten nicht geladen werden.</p>}
|
|
<label className="mt-4 flex h-12 items-center gap-2 rounded-lg border border-border px-3">
|
|
<Search className="h-4 w-4 text-primary" />
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className="flex-1 outline-none" placeholder="Kundenname, Ort, PLZ, Ansprechpartner" />
|
|
</label>
|
|
<div className="mt-4 grid gap-3">
|
|
{customerCards.map((item) => {
|
|
const active = item.id === selectedCustomerId;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setSelectedCustomerId(item.id)}
|
|
className={`rounded-lg border p-4 text-left transition ${active ? "border-primary bg-primary/5 shadow" : "border-border bg-background hover:border-primary/40 hover:bg-white"}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-base font-semibold">{item.name}</p>
|
|
<p className="mt-1 text-sm text-text-light">{item.city || "Ort nicht erfasst"}</p>
|
|
</div>
|
|
{active && <UserRoundCheck className="h-5 w-5 text-primary" />}
|
|
</div>
|
|
<p className="mt-2 text-sm text-text-light">{item.customer_type === "practice" ? "Praxis" : "Klinik"}</p>
|
|
</button>
|
|
);
|
|
})}
|
|
{customers.isLoading && <p className="rounded-lg border border-dashed border-border p-4 text-sm text-text-light">Kunden werden geladen...</p>}
|
|
{!customers.isLoading && !customers.isError && customerCards.length === 0 && <p className="rounded-lg border border-dashed border-border p-4 text-sm text-text-light">Keine Kunden gefunden.</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-xl font-semibold">2. Gerät</h2>
|
|
{selectedDevice && <span className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">Ausgewählt</span>}
|
|
</div>
|
|
{customerData.isError && <p className="mt-3 rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Kundendaten konnten nicht geladen werden.</p>}
|
|
<div className="mt-4 grid gap-3">
|
|
{(customerData.data?.devices ?? []).map((item) => {
|
|
const active = item.id === selectedDeviceId;
|
|
const latest = customerData.data?.validations.find((validation) => validation.device_id === item.id) ?? null;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setSelectedDeviceId(item.id)}
|
|
className={`rounded-lg border p-4 text-left transition ${active ? "border-primary bg-primary/5 shadow" : "border-border bg-background hover:border-primary/40 hover:bg-white"}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-base font-semibold">{item.manufacturer} {item.model}</p>
|
|
<p className="mt-1 text-sm text-text-light">Seriennummer {item.serial_number}</p>
|
|
<p className="mt-1 text-sm text-text-light">{item.location_id ? "Standort hinterlegt" : "Standort nicht erfasst"}</p>
|
|
</div>
|
|
{active && <UserRoundCheck className="h-5 w-5 text-primary" />}
|
|
</div>
|
|
<div className="mt-3 grid gap-2 text-sm text-text-light sm:grid-cols-2">
|
|
<span>{latest ? `Letzte Validierung: ${latest.report_number}` : "Keine letzte Validierung erfasst"}</span>
|
|
<span>{latest?.next_validation_on ? `Naechste Revalidierung: ${latest.next_validation_on}` : "Naechste Revalidierung nicht erfasst"}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
{customerData.isLoading && selectedCustomerId && <p className="rounded-lg border border-dashed border-border p-4 text-sm text-text-light">Geräte werden geladen...</p>}
|
|
{selectedCustomerId && !customerData.isLoading && !customerData.isError && (customerData.data?.devices.length ?? 0) === 0 && <p className="rounded-lg border border-dashed border-border p-4 text-sm text-text-light">Keine Geräte gefunden.</p>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-4 lg:grid-cols-3">
|
|
<div className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
|
<h2 className="text-xl font-semibold">3. Validierungsart</h2>
|
|
<div className="mt-4 grid gap-3">
|
|
{Object.entries(validationTypeLabels).map(([value, label]) => {
|
|
const active = validationType === value;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setValidationType(value)}
|
|
className={`rounded-lg border px-4 py-3 text-left transition ${active ? "border-primary bg-primary/5" : "border-border bg-background hover:border-primary/40 hover:bg-white"}`}
|
|
>
|
|
<p className="font-medium">{label}</p>
|
|
<p className="mt-1 text-sm text-text-light">
|
|
{value === "Erstvalidierung" ? "Neue Vorlage mit Standardchecklisten." : value === "Revalidierung" ? "Letzte abgeschlossene oder freigegebene Validierung als Basis." : "Letzte Validierung als leistungsbezogene Grundlage."}
|
|
</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-surface p-5 shadow-soft lg:col-span-2">
|
|
<h2 className="text-xl font-semibold">4. Übernahme vorhandener Daten</h2>
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<SelectionCard
|
|
label="Standort"
|
|
value={selectedLocationId}
|
|
placeholder="Bitte auswählen"
|
|
emptyText={selectedCustomerId ? "Kein Standort für diesen Kunden vorhanden." : "Bitte zuerst einen Kunden auswählen."}
|
|
options={locations.map((item) => ({ value: item.id, label: [item.name, item.room, item.city].filter(Boolean).join(" · ") }))}
|
|
onChange={(value) => {
|
|
setSelectedLocationId(value);
|
|
setSelectedContactId(contacts.length === 1 ? contacts[0].id : "");
|
|
}}
|
|
required
|
|
/>
|
|
<SelectionCard
|
|
label="Ansprechpartner"
|
|
value={selectedContactId}
|
|
placeholder="Optional auswählen"
|
|
emptyText={selectedCustomerId ? "Kein Ansprechpartner für diesen Kunden vorhanden." : "Bitte zuerst einen Kunden auswählen."}
|
|
options={contacts.map((item) => ({ value: item.id, label: [item.full_name, item.function].filter(Boolean).join(" · ") }))}
|
|
onChange={setSelectedContactId}
|
|
/>
|
|
<InfoCard label="Prüfer" value={currentExaminer} />
|
|
<InfoCard label="Vorherige Validierung" value={latestValidation ? latestValidation.report_number : "Keine vorhanden"} />
|
|
</div>
|
|
{(customerData.data?.validations.length ?? 0) > 0 && (
|
|
<div className="mt-5 rounded-lg border border-border bg-background p-4">
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-light">Letzte Validierungen</h3>
|
|
<div className="mt-3 grid gap-3">
|
|
{customerData.data?.validations.slice(0, 3).map((item) => (
|
|
<div key={item.id} className="flex flex-col gap-1 rounded-lg border border-border bg-white px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="font-medium text-text">{item.report_number}</p>
|
|
<p className="text-text-light">{item.validation_type ?? "Validierung"} · {item.performed_on ?? "nicht erfasst"}</p>
|
|
</div>
|
|
<p className="text-text-light">{item.next_validation_on ? `Naechste Revalidierung: ${item.next_validation_on}` : "Naechste Revalidierung nicht erfasst"}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedCustomer && (
|
|
<div className="mt-4 rounded-lg border border-border bg-background p-4 text-sm text-text-light">
|
|
{customerData.isLoading ? "Vorhandene Daten werden geladen..." : "Vorhandene Daten stehen bereit."}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<footer className="sticky bottom-4 z-20 rounded-lg border border-border bg-surface/95 p-4 shadow-soft backdrop-blur">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div className="text-sm text-text-light">
|
|
{error ? <p className="text-danger">{error}</p> : <p>Nach dem Erstellen öffnet sich direkt die Bearbeitungsseite.</p>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={!canCreate}
|
|
title={!canCreate ? disabledReason : undefined}
|
|
onClick={() => createMutation.mutate()}
|
|
className="btn btn-primary h-12 min-w-64"
|
|
>
|
|
{createMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowRight className="h-4 w-4" />}
|
|
Validierung erstellen und bearbeiten
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoCard({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="rounded-lg border border-border bg-white p-4">
|
|
<p className="text-xs uppercase tracking-wide text-text-light">{label}</p>
|
|
<p className="mt-2 font-medium text-text">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectionCard({
|
|
label,
|
|
value,
|
|
options,
|
|
placeholder,
|
|
emptyText,
|
|
onChange,
|
|
required = false
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
options: { value: string; label: string }[];
|
|
placeholder: string;
|
|
emptyText: string;
|
|
onChange: (value: string) => void;
|
|
required?: boolean;
|
|
}) {
|
|
const disabled = options.length === 0;
|
|
return (
|
|
<label className="block rounded-lg border border-border bg-white p-4 transition hover:border-primary/40 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/15">
|
|
<span className="text-xs uppercase tracking-wide text-text-light">{label}{required && " *"}</span>
|
|
<select
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
disabled={disabled}
|
|
className="mt-2 h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium text-text outline-none transition hover:bg-white focus:border-primary disabled:cursor-not-allowed disabled:bg-border/30 disabled:text-text-light"
|
|
>
|
|
<option value="">{disabled ? emptyText : placeholder}</option>
|
|
{options.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
|
</select>
|
|
{disabled && <p className="mt-2 text-sm text-text-light">{emptyText}</p>}
|
|
</label>
|
|
);
|
|
}
|