1006 lines
48 KiB
TypeScript
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>
|
|
);
|
|
}
|