Files
Calbook/components/admin/calendar-person-panel.tsx

1006 lines
48 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import {
Calendar,
CalendarCheck,
CalendarX,
CheckCircle2,
ChevronDown,
ChevronRight,
Globe,
Link,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Power,
PowerOff,
RefreshCw,
Save,
Settings,
Terminal,
Trash2,
UserPlus,
X
} from "lucide-react";
import {
createDefaultWeekdayAvailability,
createWeekdayAvailabilityFromLegacy,
hasAtLeastOneEnabledDay,
isValidTimeValue,
parseWeekdayAvailabilityJson,
type WeekdayAvailability,
type WeekdayKey
} from "@/lib/weekday-availability";
import { cn } from "@/lib/utils";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
type PersonCalendarResource = {
id: string;
personId: string;
personName: string;
personBio: string | null;
isActive: boolean;
upcomingAppointments: number;
calendarName: string;
bookingAllowedWeekdays: string;
bookingDayStartTime: string;
bookingDayEndTime: string;
bookingDayRangesJson: string | null;
url: string;
username: string;
notificationEmail: string | null;
color: string | null;
syncEnabled: boolean;
lastSyncedAt: string | null;
syncError: string | null;
createdAt: string;
};
type ResourceResponse = {
resources: PersonCalendarResource[];
meta: { employeeCount: number };
};
type SyncRunStatus = "RUNNING" | "SUCCESS" | "FAILED";
type SyncLogEntry = {
id: string;
level: string;
message: string;
createdAt: string;
};
type SyncRun = {
id: string;
calendarConnId: string;
status: SyncRunStatus;
message: string | null;
startedAt: string;
finishedAt: string | null;
entries: SyncLogEntry[];
};
type SyncRunResponse = {
run: SyncRun | null;
};
type CalendarDraft = {
resourceName: string;
resourceBio: string;
calendarName: string;
calendarUrl: string;
calendarUsername: string;
notificationEmail: string;
calendarPassword: string;
syncEnabled: boolean;
resourceActive: boolean;
availability: WeekdayAvailability;
};
type ConnectionTestState = {
status: "idle" | "loading" | "success" | "error";
message: string;
calendars: Array<{ name: string; url: string }>;
};
const WEEKDAY_OPTIONS = [
{ value: "0", label: "Mo" },
{ value: "1", label: "Di" },
{ value: "2", label: "Mi" },
{ value: "3", label: "Do" },
{ value: "4", label: "Fr" },
{ value: "5", label: "Sa" },
{ value: "6", label: "So" }
] as const;
const SETUP_STEPS = ["Person", "Verbindung", "Verfügbarkeit", "Prüfen"] as const;
function createEmptyDraft(): CalendarDraft {
return {
resourceName: "",
resourceBio: "",
calendarName: "",
calendarUrl: "",
calendarUsername: "",
notificationEmail: "",
calendarPassword: "",
syncEnabled: true,
resourceActive: true,
availability: createDefaultWeekdayAvailability()
};
}
function emptyConnectionTest(): ConnectionTestState {
return { status: "idle", message: "Noch nicht getestet.", calendars: [] };
}
function getAvailabilityFromResource(resource: PersonCalendarResource): WeekdayAvailability {
const fallback = createWeekdayAvailabilityFromLegacy(
resource.bookingAllowedWeekdays,
resource.bookingDayStartTime,
resource.bookingDayEndTime
);
return parseWeekdayAvailabilityJson(resource.bookingDayRangesJson, fallback);
}
function updateDayState(
availability: WeekdayAvailability,
day: WeekdayKey,
patch: Partial<WeekdayAvailability[WeekdayKey]>
) {
return { ...availability, [day]: { ...availability[day], ...patch } };
}
function hasInvalidEnabledRanges(availability: WeekdayAvailability) {
return WEEKDAY_OPTIONS.some((option) => {
const day = availability[option.value];
return day.enabled && (!isValidTimeValue(day.start) || !isValidTimeValue(day.end) || day.start >= day.end);
});
}
function formatAvailabilitySummary(availability: WeekdayAvailability) {
const parts = WEEKDAY_OPTIONS.filter((option) => availability[option.value].enabled).map(
(option) => {
const range = availability[option.value];
return `${option.label} ${range.start}-${range.end}`;
}
);
return parts.length > 0 ? parts.join(" · ") : "Keine aktiven Tage";
}
function runStatusLabel(status: SyncRunStatus) {
if (status === "RUNNING") return "Läuft";
if (status === "SUCCESS") return "Erfolgreich";
return "Fehlgeschlagen";
}
function runStatusClass(status: SyncRunStatus) {
if (status === "RUNNING") return "bg-amber-100 text-amber-800";
if (status === "SUCCESS") return "bg-emerald-100 text-emerald-800";
return "bg-red-100 text-red-800";
}
function logLevelClass(level: string) {
const normalized = level.toUpperCase();
if (normalized === "ERROR") return "text-red-300";
if (normalized === "WARN") return "text-amber-300";
return "text-slate-200";
}
function draftFromResource(resource: PersonCalendarResource): CalendarDraft {
return {
resourceName: resource.personName,
resourceBio: resource.personBio ?? "",
calendarName: resource.calendarName,
calendarUrl: resource.url,
calendarUsername: resource.username,
notificationEmail: resource.notificationEmail ?? "",
calendarPassword: "",
syncEnabled: resource.syncEnabled,
resourceActive: resource.isActive,
availability: getAvailabilityFromResource(resource)
};
}
function AvailabilityEditor({
value,
onChange
}: {
value: WeekdayAvailability;
onChange: (next: WeekdayAvailability) => void;
}) {
return (
<div className="space-y-1.5">
{WEEKDAY_OPTIONS.map((option) => {
const day = value[option.value];
return (
<div
key={option.value}
className="grid gap-2 rounded-lg border border-slate-200 bg-white p-2 sm:grid-cols-[80px_1fr_1fr]"
>
<label className="inline-flex items-center gap-2 text-xs font-bold text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={day.enabled}
onChange={(e) => onChange(updateDayState(value, option.value, { enabled: e.target.checked }))}
/>
{option.label}
</label>
<Input
aria-label={`${option.label} Startzeit`}
type="time"
value={day.start}
disabled={!day.enabled}
onChange={(e) => onChange(updateDayState(value, option.value, { start: e.target.value }))}
className="h-9 text-xs"
/>
<Input
aria-label={`${option.label} Endzeit`}
type="time"
value={day.end}
disabled={!day.enabled}
onChange={(e) => onChange(updateDayState(value, option.value, { end: e.target.value }))}
className="h-9 text-xs"
/>
</div>
);
})}
</div>
);
}
function ConnectionTestBox({ state }: { state: ConnectionTestState }) {
return (
<div
className={cn(
"rounded-xl border p-3 text-sm",
state.status === "success" && "border-emerald-200 bg-emerald-50 text-emerald-900",
state.status === "error" && "border-red-200 bg-red-50 text-red-900",
state.status === "loading" && "border-blue-200 bg-blue-50 text-blue-800",
state.status === "idle" && "border-slate-200 bg-slate-50 text-slate-600"
)}
>
<div className="flex items-center gap-2">
{state.status === "loading" && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
{state.status === "success" && <CheckCircle2 className="h-4 w-4 text-emerald-600" />}
{state.status === "error" && <X className="h-4 w-4 text-red-500" />}
<p className="font-bold text-xs">{state.status === "loading" ? "Verbindung wird getestet ..." : state.message}</p>
</div>
{state.calendars.length > 0 && (
<ul className="mt-2 space-y-1 text-xs">
{state.calendars.map((c) => (
<li key={`${c.name}-${c.url}`} className="break-all">
{c.name}{c.url ? `${c.url}` : ""}
</li>
))}
</ul>
)}
</div>
);
}
function validateDraft(draft: CalendarDraft, options: { passwordRequired: boolean }) {
if (!draft.resourceName.trim()) { toast.error("Bitte einen Personennamen eintragen."); return false; }
if (!draft.calendarUrl.trim() || !draft.calendarUsername.trim()) { toast.error("Bitte CalDAV-URL und Benutzername eintragen."); return false; }
if (options.passwordRequired && !draft.calendarPassword.trim()) { toast.error("Bitte das CalDAV-Passwort eintragen."); return false; }
if (!draft.notificationEmail.trim()) { toast.error("Bitte eine Benachrichtigungs-E-Mail eintragen."); return false; }
if (!hasAtLeastOneEnabledDay(draft.availability)) { toast.error("Bitte mindestens einen verfügbaren Wochentag aktivieren."); return false; }
if (hasInvalidEnabledRanges(draft.availability)) { toast.error("Bitte pro aktivem Tag ein gültiges Zeitfenster setzen (Von < Bis)."); return false; }
return true;
}
export function CalendarPersonPanel() {
const [resources, setResources] = useState<PersonCalendarResource[]>([]);
const [employeeCount, setEmployeeCount] = useState(0);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [createDraft, setCreateDraft] = useState<CalendarDraft>(createEmptyDraft);
const [createStep, setCreateStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [connectionTest, setConnectionTest] = useState<ConnectionTestState>(emptyConnectionTest);
const [expandedResource, setExpandedResource] = useState<string | null>(null);
const [editingResource, setEditingResource] = useState<PersonCalendarResource | null>(null);
const [editDraft, setEditDraft] = useState<CalendarDraft | null>(null);
const [editSaving, setEditSaving] = useState(false);
const [editConnectionTest, setEditConnectionTest] = useState<ConnectionTestState>(emptyConnectionTest);
const [syncRunByResource, setSyncRunByResource] = useState<Record<string, SyncRun | null>>({});
const [logOpenByResource, setLogOpenByResource] = useState<Record<string, boolean>>({});
const [activeRunIdByResource, setActiveRunIdByResource] = useState<Record<string, string>>({});
const [syncingByResource, setSyncingByResource] = useState<Record<string, boolean>>({});
const [deleteConfirm, setDeleteConfirm] = useState<PersonCalendarResource | null>(null);
async function loadResources() {
setLoading(true);
try {
const res = await fetch("/api/admin/kalender", { cache: "no-store" });
const data = (await res.json()) as ResourceResponse & { message?: string };
if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnten nicht geladen werden."); return; }
setResources(data.resources ?? []);
setEmployeeCount(data.meta?.employeeCount ?? 0);
} catch { toast.error("Personen-Kalender konnten nicht geladen werden."); }
finally { setLoading(false); }
}
useEffect(() => { void loadResources(); }, []);
useEffect(() => {
const activeEntries = Object.entries(activeRunIdByResource);
if (activeEntries.length === 0) return;
const timer = window.setInterval(() => {
for (const [resourceId, runId] of activeEntries) {
void fetchSyncRun(resourceId, runId, true);
}
}, 1000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRunIdByResource]);
function updateCreateDraft(patch: Partial<CalendarDraft>) {
setCreateDraft((prev) => ({ ...prev, ...patch }));
if ("calendarUrl" in patch || "calendarUsername" in patch || "calendarPassword" in patch) {
setConnectionTest(emptyConnectionTest());
}
}
function updateEditDraft(patch: Partial<CalendarDraft>) {
setEditDraft((prev) => (prev ? { ...prev, ...patch } : prev));
if ("calendarUrl" in patch || "calendarUsername" in patch || "calendarPassword" in patch) {
setEditConnectionTest(emptyConnectionTest());
}
}
async function fetchSyncRun(resourceId: string, runId?: string, silent = false) {
try {
const query = runId ? `?runId=${encodeURIComponent(runId)}` : "";
const res = await fetch(`/api/admin/kalender/${resourceId}/sync${query}`, { cache: "no-store" });
const data = (await res.json()) as SyncRunResponse & { message?: string };
if (!res.ok) { if (!silent) toast.error(data?.message ?? "Sync-Log konnte nicht geladen werden."); return; }
const run = data.run;
setSyncRunByResource((prev) => ({ ...prev, [resourceId]: run }));
if (run?.status === "RUNNING") {
setActiveRunIdByResource((prev) => ({ ...prev, [resourceId]: run.id }));
return;
}
setActiveRunIdByResource((prev) => { const next = { ...prev }; delete next[resourceId]; return next; });
if (run) await loadResources();
} catch { if (!silent) toast.error("Sync-Log konnte nicht geladen werden."); }
}
async function testConnectionForDraft(draft: CalendarDraft, setState: (s: ConnectionTestState) => void) {
if (!draft.calendarUrl.trim() || !draft.calendarUsername.trim() || !draft.calendarPassword.trim()) {
toast.error("Bitte URL, Benutzername und Passwort für den Verbindungstest eintragen.");
return;
}
setState({ status: "loading", message: "Verbindung wird getestet ...", calendars: [] });
try {
const res = await fetch("/api/admin/kalender/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: draft.calendarUrl.trim(), username: draft.calendarUsername.trim(), password: draft.calendarPassword })
});
const data = (await res.json()) as { message?: string; calendarCount?: number; calendars?: Array<{ name: string; url: string }> };
if (!res.ok) { setState({ status: "error", message: data?.message ?? "CalDAV-Verbindung fehlgeschlagen.", calendars: [] }); return; }
setState({ status: "success", message: data?.message ?? "Verbindung erfolgreich getestet.", calendars: data.calendars ?? [] });
} catch { setState({ status: "error", message: "CalDAV-Verbindung fehlgeschlagen.", calendars: [] }); }
}
function goToNextCreateStep() {
if (createStep === 0) { if (!createDraft.resourceName.trim() || !createDraft.notificationEmail.trim()) { toast.error("Bitte Personenname und Benachrichtigungs-E-Mail eintragen."); return; } }
if (createStep === 1) { if (!createDraft.calendarUrl.trim() || !createDraft.calendarUsername.trim() || !createDraft.calendarPassword.trim()) { toast.error("Bitte CalDAV-URL, Benutzername und Passwort eintragen."); return; } }
if (createStep === 2) { if (!hasAtLeastOneEnabledDay(createDraft.availability) || hasInvalidEnabledRanges(createDraft.availability)) { toast.error("Bitte gültige Verfügbarkeiten eintragen."); return; } }
setCreateStep((prev) => Math.min(prev + 1, SETUP_STEPS.length - 1));
}
async function createResource() {
if (!validateDraft(createDraft, { passwordRequired: true })) return;
setSubmitting(true);
try {
const res = await fetch("/api/admin/kalender", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
resourceName: createDraft.resourceName.trim(),
resourceBio: createDraft.resourceBio.trim() || undefined,
isActive: createDraft.resourceActive,
calendarName: createDraft.calendarName.trim() || createDraft.resourceName.trim(),
bookingDayRanges: createDraft.availability,
url: createDraft.calendarUrl.trim(),
username: createDraft.calendarUsername.trim(),
notificationEmail: createDraft.notificationEmail.trim().toLowerCase(),
password: createDraft.calendarPassword,
syncEnabled: createDraft.syncEnabled
})
});
const data = await res.json();
if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht erstellt werden."); return; }
setCreateDraft(createEmptyDraft());
setCreateStep(0);
setConnectionTest(emptyConnectionTest());
setWizardOpen(false);
toast.success("Personen-Kalender angelegt");
await loadResources();
} finally { setSubmitting(false); }
}
function startEdit(resource: PersonCalendarResource) {
setEditingResource(resource);
setEditDraft(draftFromResource(resource));
setEditConnectionTest({
status: "idle",
message: "Zum Testen gespeicherter Zugangsdaten bitte ein neues Passwort eintragen oder den Sync ausführen.",
calendars: []
});
}
function cancelEdit() {
setEditingResource(null);
setEditDraft(null);
setEditConnectionTest(emptyConnectionTest());
}
async function saveEdit() {
if (!editingResource || !editDraft) return;
if (!validateDraft(editDraft, { passwordRequired: false })) return;
setEditSaving(true);
try {
const payload: Record<string, unknown> = {
resourceName: editDraft.resourceName.trim(),
resourceBio: editDraft.resourceBio,
isActive: editDraft.resourceActive,
calendarName: editDraft.calendarName.trim() || editDraft.resourceName.trim(),
bookingDayRanges: editDraft.availability,
url: editDraft.calendarUrl.trim(),
username: editDraft.calendarUsername.trim(),
notificationEmail: editDraft.notificationEmail.trim().toLowerCase(),
syncEnabled: editDraft.syncEnabled
};
if (editDraft.calendarPassword.trim()) payload.password = editDraft.calendarPassword;
const res = await fetch(`/api/admin/kalender/${editingResource.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht gespeichert werden."); return; }
toast.success("Personen-Kalender gespeichert");
cancelEdit();
await loadResources();
} finally { setEditSaving(false); }
}
async function syncResource(id: string) {
setSyncingByResource((prev) => ({ ...prev, [id]: true }));
try {
const res = await fetch(`/api/admin/kalender/${id}/sync`, { method: "POST" });
const data = (await res.json()) as { message?: string; runId?: string };
if (!res.ok || !data.runId) { toast.error(data?.message ?? "Sync fehlgeschlagen"); return; }
setLogOpenByResource((prev) => ({ ...prev, [id]: true }));
setActiveRunIdByResource((prev) => ({ ...prev, [id]: data.runId as string }));
toast.success("Synchronisierung gestartet");
await fetchSyncRun(id, data.runId, true);
} finally { setSyncingByResource((prev) => ({ ...prev, [id]: false })); }
}
async function toggleLog(resourceId: string) {
const isOpen = logOpenByResource[resourceId] === true;
if (isOpen) { setLogOpenByResource((prev) => ({ ...prev, [resourceId]: false })); return; }
setLogOpenByResource((prev) => ({ ...prev, [resourceId]: true }));
await fetchSyncRun(resourceId, activeRunIdByResource[resourceId], false);
}
async function toggleResourceActive(resource: PersonCalendarResource) {
const res = await fetch(`/api/admin/kalender/${resource.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !resource.isActive })
});
const data = await res.json();
if (!res.ok) { toast.error(data?.message ?? "Status konnte nicht geändert werden."); return; }
toast.success(resource.isActive ? "Person deaktiviert" : "Person aktiviert");
await loadResources();
}
async function toggleSync(resource: PersonCalendarResource) {
const res = await fetch(`/api/admin/kalender/${resource.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ syncEnabled: !resource.syncEnabled })
});
const data = await res.json();
if (!res.ok) { toast.error(data?.message ?? "Sync-Status konnte nicht geändert werden."); return; }
toast.success(resource.syncEnabled ? "Sync deaktiviert" : "Sync aktiviert");
await loadResources();
}
async function deleteResource(resource: PersonCalendarResource) {
setDeleteConfirm(resource);
}
async function execDelete() {
if (!deleteConfirm) return;
const res = await fetch(`/api/admin/kalender/${deleteConfirm.id}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht entfernt werden."); setDeleteConfirm(null); return; }
toast.success("Personen-Kalender entfernt");
setDeleteConfirm(null);
await loadResources();
}
const activeResources = resources.filter((r) => r.isActive).length;
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-3xl font-black tracking-tight text-slate-950">Kalender-Personen</h1>
<p className="mt-1 text-sm font-medium text-slate-500">
{activeResources} von {resources.length} {resources.length === 1 ? "Person" : "Personen"} aktiv ·{" "}
{employeeCount} buchbare {employeeCount === 1 ? "Kapazität" : "Kapazitäten"}
</p>
</div>
{!wizardOpen && (
<Button
type="button"
onClick={() => setWizardOpen(true)}
className="shrink-0"
>
<UserPlus className="mr-2 h-4 w-4" />
Neue Person anlegen
</Button>
)}
</div>
{/* Setup Wizard (collapsible) */}
{wizardOpen && (
<div className="mb-6 rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50 px-5 py-3">
<div className="flex items-center gap-2">
<UserPlus className="h-4 w-4 text-slate-500" />
<h2 className="text-sm font-black text-slate-900">Neue Person anlegen</h2>
</div>
<button
type="button"
onClick={() => { setWizardOpen(false); setCreateStep(0); }}
className="rounded-lg p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5">
{/* Step indicators */}
<div className="mb-5 flex gap-2">
{SETUP_STEPS.map((step, index) => (
<button
key={step}
type="button"
onClick={() => setCreateStep(index)}
className={cn(
"flex-1 rounded-xl border px-3 py-2.5 text-left text-xs font-bold transition-all",
createStep === index
? "border-slate-900 bg-slate-900 text-white shadow-sm"
: createStep > index
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
)}
>
<span className="block text-[10px] opacity-60">Schritt {index + 1}</span>
{createStep > index ? <span className="flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> {step}</span> : step}
</button>
))}
</div>
{/* Step 0: Person */}
{createStep === 0 && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="resource-name">Personenname*</Label>
<Input id="resource-name" value={createDraft.resourceName} onChange={(e) => updateCreateDraft({ resourceName: e.target.value })} placeholder="z. B. Jonas Keil" />
</div>
<div className="space-y-1.5">
<Label htmlFor="calendar-name">Kalendername</Label>
<Input id="calendar-name" value={createDraft.calendarName} onChange={(e) => updateCreateDraft({ calendarName: e.target.value })} placeholder="Leer = Personenname" />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="notification-email">Benachrichtigungs-E-Mail*</Label>
<Input id="notification-email" type="email" value={createDraft.notificationEmail} onChange={(e) => updateCreateDraft({ notificationEmail: e.target.value })} placeholder="name@beispiel.de" />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="resource-bio">Kurzprofil</Label>
<Textarea id="resource-bio" value={createDraft.resourceBio} onChange={(e) => updateCreateDraft({ resourceBio: e.target.value })} placeholder="Interne Orientierung, optional" className="min-h-[80px]" />
</div>
<div className="flex flex-wrap items-center gap-5 md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-slate-700 cursor-pointer">
<input type="checkbox" checked={createDraft.syncEnabled} onChange={(e) => updateCreateDraft({ syncEnabled: e.target.checked })} />
CalDAV-Sync aktiv
</label>
<label className="inline-flex items-center gap-2 text-sm text-slate-700 cursor-pointer">
<input type="checkbox" checked={createDraft.resourceActive} onChange={(e) => updateCreateDraft({ resourceActive: e.target.checked })} />
Sofort buchbar
</label>
</div>
</div>
)}
{/* Step 1: Verbindung */}
{createStep === 1 && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="calendar-url">CalDAV-URL*</Label>
<Input id="calendar-url" value={createDraft.calendarUrl} onChange={(e) => updateCreateDraft({ calendarUrl: e.target.value })} placeholder="https://..." />
</div>
<div className="space-y-1.5">
<Label htmlFor="calendar-username">Benutzername*</Label>
<Input id="calendar-username" value={createDraft.calendarUsername} onChange={(e) => updateCreateDraft({ calendarUsername: e.target.value })} autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="calendar-password">Passwort*</Label>
<Input id="calendar-password" type="password" value={createDraft.calendarPassword} onChange={(e) => updateCreateDraft({ calendarPassword: e.target.value })} autoComplete="new-password" />
</div>
<div className="space-y-3 md:col-span-2">
<Button type="button" variant="secondary" onClick={() => void testConnectionForDraft(createDraft, setConnectionTest)} disabled={connectionTest.status === "loading"}>
Verbindung testen
</Button>
<ConnectionTestBox state={connectionTest} />
</div>
</div>
)}
{/* Step 2: Verfügbarkeit */}
{createStep === 2 && (
<div>
<p className="mb-2 text-sm font-bold text-slate-900">Buchbare Zeiten</p>
<p className="mb-3 text-xs text-slate-500">Nur aktive Tage werden öffentlich im Kalender angeboten.</p>
<AvailabilityEditor value={createDraft.availability} onChange={(availability) => updateCreateDraft({ availability })} />
</div>
)}
{/* Step 3: Prüfen */}
{createStep === 3 && (
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm">
<p className="mb-2 font-bold text-slate-900">Zusammenfassung</p>
<div className="grid grid-cols-2 gap-2 text-slate-600">
<p>Person:</p><p className="font-medium text-slate-900">{createDraft.resourceName || "-"}</p>
<p>Kalender:</p><p className="font-medium text-slate-900">{createDraft.calendarName || createDraft.resourceName || "-"}</p>
<p>E-Mail:</p><p className="font-medium text-slate-900">{createDraft.notificationEmail || "-"}</p>
</div>
<p className="mt-2 break-all text-xs text-slate-500">URL: {createDraft.calendarUrl || "-"}</p>
<p className="mt-1 text-xs text-slate-500">Verfügbarkeit: {formatAvailabilitySummary(createDraft.availability)}</p>
</div>
<div>
<ConnectionTestBox state={connectionTest} />
<p className="mt-2 text-xs text-slate-400">Empfehlung: Verbindung vor dem Speichern testen.</p>
</div>
</div>
)}
{/* Wizard navigation */}
<div className="mt-5 flex justify-between border-t border-slate-100 pt-4">
<Button type="button" variant="secondary" onClick={() => setCreateStep((prev) => Math.max(prev - 1, 0))} disabled={createStep === 0 || submitting}>
Zurück
</Button>
{createStep < SETUP_STEPS.length - 1 ? (
<Button type="button" onClick={goToNextCreateStep} disabled={submitting}>Weiter</Button>
) : (
<Button type="button" onClick={() => void createResource()} disabled={submitting}>
{submitting ? "Wird angelegt..." : "Personen-Kalender anlegen"}
</Button>
)}
</div>
</div>
</div>
)}
{/* Resource list */}
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<Calendar className="h-4 w-4 text-slate-400" />
<h2 className="text-sm font-black text-slate-900">
{resources.length} {resources.length === 1 ? "Personen-Kalender" : "Personen-Kalender"}
</h2>
</div>
{loading ? (
<div className="space-y-3 p-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
) : resources.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Calendar className="mb-3 h-12 w-12 opacity-20" />
<p className="text-sm font-bold">Noch kein Personen-Kalender vorhanden</p>
<p className="mt-1 text-xs">Lege oben die erste Person an und teste danach die CalDAV-Verbindung.</p>
{!wizardOpen && (
<Button type="button" onClick={() => setWizardOpen(true)} className="mt-4">
<Plus className="mr-2 h-4 w-4" /> Erste Person anlegen
</Button>
)}
</div>
) : (
<div className="divide-y divide-slate-100">
{resources.map((resource) => {
const syncRun = syncRunByResource[resource.id];
const isRunning = syncRun?.status === "RUNNING" || Boolean(activeRunIdByResource[resource.id]);
const isSyncing = syncingByResource[resource.id] === true;
const logOpen = logOpenByResource[resource.id] === true;
const expanded = expandedResource === resource.id;
const isEditing = editingResource?.id === resource.id;
const availability = getAvailabilityFromResource(resource);
return (
<div key={resource.id} className={cn(isEditing && "bg-slate-50/50")}>
{/* Resource header row */}
<div
className={cn(
"flex items-center gap-3 px-5 py-4 transition cursor-pointer hover:bg-slate-50",
isEditing && "cursor-default"
)}
onClick={() => {
if (!isEditing) setExpandedResource(expanded ? null : resource.id);
}}
>
<div className={cn("transition-transform", expanded && !isEditing && "rotate-90")}>
<ChevronRight className="h-5 w-5 text-slate-300" />
</div>
{/* Status dot */}
<div className={cn("h-2.5 w-2.5 rounded-full shrink-0", resource.isActive ? "bg-emerald-500" : "bg-slate-300")} />
{/* Name + email */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-slate-900 truncate">{resource.personName}</p>
</div>
<p className="text-xs text-slate-500 truncate">{resource.notificationEmail || "-"}</p>
</div>
{/* Badges */}
<div className="hidden sm:flex items-center gap-1.5 shrink-0">
{resource.syncEnabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700">
<RefreshCw className="h-3 w-3" /> Sync
</span>
) : null}
{resource.syncError && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-700">
Fehler
</span>
)}
<span className="text-xs font-medium text-slate-400">
{resource.upcomingAppointments} kommend
</span>
</div>
{/* Quick actions (stop propagation to prevent expand toggle) */}
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => { startEdit(resource); setExpandedResource(resource.id); }}
className="rounded-lg p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition"
title="Bearbeiten"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void syncResource(resource.id)}
disabled={isSyncing || isRunning}
className="rounded-lg p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 transition disabled:opacity-40"
title="Jetzt synchronisieren"
>
<RefreshCw className={cn("h-4 w-4", (isSyncing || isRunning) && "animate-spin")} />
</button>
<button
type="button"
onClick={() => void toggleResourceActive(resource)}
className="rounded-lg p-1.5 text-slate-400 hover:text-amber-600 hover:bg-amber-50 transition"
title={resource.isActive ? "Deaktivieren" : "Aktivieren"}
>
{resource.isActive ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
</button>
</div>
</div>
{/* Expanded detail panel */}
{expanded && !isEditing && (
<div className="border-t border-slate-100 bg-slate-50/50 px-5 py-4 animate-in fade-in slide-in-from-top-1 duration-150">
<div className="grid gap-3 text-xs md:grid-cols-2">
<div>
<p className="font-bold uppercase tracking-wider text-slate-400 mb-1">Details</p>
<p><span className="text-slate-400">Kalender:</span> <span className="font-medium text-slate-900">{resource.calendarName}</span></p>
<p><span className="text-slate-400">Benutzer:</span> <span className="font-medium text-slate-900">{resource.username}</span></p>
<p className="break-all"><span className="text-slate-400">URL:</span> <span className="font-medium text-slate-900">{resource.url}</span></p>
{resource.personBio && <p className="mt-1 text-slate-500 italic">{resource.personBio}</p>}
</div>
<div>
<p className="font-bold uppercase tracking-wider text-slate-400 mb-1">Status</p>
<p>
<span className="text-slate-400">Letzter Sync:</span>{" "}
<span className="font-medium text-slate-900">
{resource.lastSyncedAt ? format(new Date(resource.lastSyncedAt), "dd.MM.yyyy HH:mm", { locale: de }) : "-"}
</span>
</p>
<p><span className="text-slate-400">Sync:</span> <span className={cn("font-medium", resource.syncEnabled ? "text-blue-700" : "text-slate-400")}>{resource.syncEnabled ? "Aktiv" : "Deaktiviert"}</span></p>
<p><span className="text-slate-400">Verfügbarkeit:</span> <span className="font-medium text-slate-900">{formatAvailabilitySummary(availability)}</span></p>
</div>
{resource.syncError && (
<div className="md:col-span-2 rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
<span className="font-bold">Sync-Fehler:</span> {resource.syncError}
</div>
)}
</div>
{/* Expanded actions */}
<div className="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-3">
<Button type="button" size="sm" variant="secondary" onClick={() => startEdit(resource)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Bearbeiten
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void syncResource(resource.id)} disabled={isSyncing || isRunning}>
<RefreshCw className={cn("mr-1.5 h-3.5 w-3.5", (isSyncing || isRunning) && "animate-spin")} />
{isRunning ? "Sync läuft..." : "Jetzt synchronisieren"}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void toggleLog(resource.id)}>
<Terminal className="mr-1.5 h-3.5 w-3.5" />
{logOpen ? "Live-Log ausblenden" : "Live-Log anzeigen"}
</Button>
<div className="flex-1" />
<Button type="button" size="sm" variant="secondary" onClick={() => void toggleSync(resource)}>
<Settings className="mr-1.5 h-3.5 w-3.5" />
Sync {resource.syncEnabled ? "deaktivieren" : "aktivieren"}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void deleteResource(resource)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" /> Entfernen
</Button>
</div>
{/* Live Log */}
{logOpen && (
<div className="mt-3 rounded-xl border border-slate-700 bg-slate-950 p-3 text-xs animate-in fade-in slide-in-from-top-1 duration-150">
<div className="mb-2 flex items-center justify-between">
<p className="font-bold text-slate-100">CalDAV-Sync Live-Log</p>
{syncRun && (
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-[10px] font-bold", runStatusClass(syncRun.status))}>
{runStatusLabel(syncRun.status)}
</span>
)}
</div>
{syncRun?.message && <p className="mb-2 text-slate-300">{syncRun.message}</p>}
<div className="max-h-48 space-y-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/70 p-2 font-mono text-[11px]">
{syncRun?.entries?.length ? (
syncRun.entries.map((entry) => (
<div key={entry.id} className="flex gap-2">
<span className="shrink-0 text-slate-500">[{format(new Date(entry.createdAt), "HH:mm:ss", { locale: de })}]</span>
<span className={cn("shrink-0", logLevelClass(entry.level))}>{entry.level.toUpperCase()}</span>
<span className="break-words text-slate-100">{entry.message}</span>
</div>
))
) : (
<p className="text-slate-500">Noch keine Log-Einträge.</p>
)}
</div>
</div>
)}
</div>
)}
{/* Inline Edit Panel */}
{isEditing && editDraft && (
<div className="border-t-2 border-indigo-200 bg-slate-50/50 px-5 py-5 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xs font-black uppercase tracking-widest text-indigo-600">Bearbeiten</p>
<h3 className="text-lg font-black text-slate-900">{resource.personName}</h3>
</div>
<button type="button" onClick={cancelEdit} className="rounded-lg p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100">
<X className="h-5 w-5" />
</button>
</div>
<div className="grid gap-5">
{/* Person info */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="edit-name">Personenname*</Label>
<Input id="edit-name" value={editDraft.resourceName} onChange={(e) => updateEditDraft({ resourceName: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-cal-name">Kalendername*</Label>
<Input id="edit-cal-name" value={editDraft.calendarName} onChange={(e) => updateEditDraft({ calendarName: e.target.value })} />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="edit-bio">Kurzprofil</Label>
<Textarea id="edit-bio" value={editDraft.resourceBio} onChange={(e) => updateEditDraft({ resourceBio: e.target.value })} className="min-h-[70px]" />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="edit-notify">Benachrichtigungs-E-Mail*</Label>
<Input id="edit-notify" type="email" value={editDraft.notificationEmail} onChange={(e) => updateEditDraft({ notificationEmail: e.target.value })} />
</div>
<div className="flex items-center gap-5 md:col-span-2">
<label className="inline-flex items-center gap-2 text-xs font-bold text-slate-600 cursor-pointer">
<input type="checkbox" checked={editDraft.syncEnabled} onChange={(e) => updateEditDraft({ syncEnabled: e.target.checked })} />
CalDAV-Sync aktiv
</label>
<label className="inline-flex items-center gap-2 text-xs font-bold text-slate-600 cursor-pointer">
<input type="checkbox" checked={editDraft.resourceActive} onChange={(e) => updateEditDraft({ resourceActive: e.target.checked })} />
Person buchbar
</label>
</div>
</div>
{/* CalDAV connection */}
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="mb-3 text-xs font-black uppercase tracking-widest text-slate-400">Verbindung</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="edit-url">CalDAV-URL*</Label>
<Input id="edit-url" value={editDraft.calendarUrl} onChange={(e) => updateEditDraft({ calendarUrl: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-user">Benutzername*</Label>
<Input id="edit-user" value={editDraft.calendarUsername} onChange={(e) => updateEditDraft({ calendarUsername: e.target.value })} autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-pass">Neues Passwort</Label>
<Input id="edit-pass" type="password" value={editDraft.calendarPassword} onChange={(e) => updateEditDraft({ calendarPassword: e.target.value })} placeholder="Leer = unverändert" autoComplete="new-password" />
</div>
<div className="space-y-3 md:col-span-2">
<Button type="button" variant="secondary" onClick={() => void testConnectionForDraft(editDraft, setEditConnectionTest)} disabled={editConnectionTest.status === "loading"}>
Verbindung testen
</Button>
<ConnectionTestBox state={editConnectionTest} />
</div>
</div>
</div>
{/* Availability */}
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="mb-1 text-xs font-black uppercase tracking-widest text-slate-400">Verfügbarkeit</p>
<p className="mb-3 text-xs text-slate-500">Änderungen wirken sich direkt auf neue Buchungen aus.</p>
<AvailabilityEditor value={editDraft.availability} onChange={(availability) => updateEditDraft({ availability })} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={cancelEdit} disabled={editSaving}>Abbrechen</Button>
<Button type="button" onClick={() => void saveEdit()} disabled={editSaving}>
<Save className="mr-1.5 h-4 w-4" />
{editSaving ? "Speichert..." : "Speichern"}
</Button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<ConfirmDialog
open={deleteConfirm !== null}
title="Personen-Kalender entfernen"
message={`"${deleteConfirm?.personName ?? ""}" wirklich entfernen? Bestehende Termine bleiben in der Liste, die Person ist danach nicht mehr buchbar.`}
confirmLabel="Entfernen"
variant="danger"
onConfirm={() => void execDelete()}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}