Previous fix missed backtick template strings. Fixed 7 more api.*() calls in appointments, deadlines, settings, and einstellungen pages.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
RefreshCw,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
Clock,
|
|
ArrowUpDown,
|
|
} from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
import type {
|
|
Tenant,
|
|
CalDAVConfig,
|
|
CalDAVSyncResponse,
|
|
} from "@/lib/types";
|
|
|
|
const SYNC_INTERVALS = [
|
|
{ value: 5, label: "5 Minuten" },
|
|
{ value: 15, label: "15 Minuten" },
|
|
{ value: 30, label: "30 Minuten" },
|
|
{ value: 60, label: "1 Stunde" },
|
|
{ value: 120, label: "2 Stunden" },
|
|
{ value: 360, label: "6 Stunden" },
|
|
];
|
|
|
|
const emptyConfig: CalDAVConfig = {
|
|
url: "",
|
|
username: "",
|
|
password: "",
|
|
calendar_path: "",
|
|
sync_enabled: false,
|
|
sync_interval_minutes: 15,
|
|
};
|
|
|
|
export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
|
|
const queryClient = useQueryClient();
|
|
const existing = (tenant.settings as Record<string, unknown>)?.caldav as
|
|
| Partial<CalDAVConfig>
|
|
| undefined;
|
|
const [config, setConfig] = useState<CalDAVConfig>({
|
|
...emptyConfig,
|
|
...existing,
|
|
});
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
// Reset form when tenant changes
|
|
useEffect(() => {
|
|
const caldav = (tenant.settings as Record<string, unknown>)?.caldav as
|
|
| Partial<CalDAVConfig>
|
|
| undefined;
|
|
setConfig({ ...emptyConfig, ...caldav });
|
|
}, [tenant]);
|
|
|
|
// Fetch sync status
|
|
const { data: syncStatus } = useQuery({
|
|
queryKey: ["caldav-status"],
|
|
queryFn: () => api.get<CalDAVSyncResponse>("/caldav/status"),
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
// Save settings
|
|
const saveMutation = useMutation({
|
|
mutationFn: (cfg: CalDAVConfig) => {
|
|
const tenantId =
|
|
typeof window !== "undefined"
|
|
? localStorage.getItem("kanzlai_tenant_id")
|
|
: null;
|
|
return api.put<Tenant>(`/tenants/${tenantId}/settings`, {
|
|
caldav: cfg,
|
|
});
|
|
},
|
|
onSuccess: (updated) => {
|
|
queryClient.setQueryData(["tenant-current"], updated);
|
|
toast.success("CalDAV-Einstellungen gespeichert");
|
|
},
|
|
onError: () => {
|
|
toast.error("Fehler beim Speichern der CalDAV-Einstellungen");
|
|
},
|
|
});
|
|
|
|
// Trigger sync
|
|
const syncMutation = useMutation({
|
|
mutationFn: () => api.post<CalDAVSyncResponse>("/caldav/sync"),
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ["caldav-status"] });
|
|
if (result.status === "ok") {
|
|
toast.success(
|
|
`Synchronisierung abgeschlossen: ${result.sync.items_pushed} gesendet, ${result.sync.items_pulled} empfangen`
|
|
);
|
|
} else {
|
|
toast.error("Synchronisierung mit Fehlern abgeschlossen");
|
|
}
|
|
},
|
|
onError: () => {
|
|
toast.error("Fehler bei der Synchronisierung");
|
|
},
|
|
});
|
|
|
|
const handleSave = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
saveMutation.mutate(config);
|
|
};
|
|
|
|
const hasConfig = config.url && config.username && config.password;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* CalDAV Configuration Form */}
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="sm:col-span-2">
|
|
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
|
CalDAV-Server URL
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={config.url}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({ ...c, url: e.target.value }))
|
|
}
|
|
placeholder="https://dav.example.com/dav"
|
|
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
|
Benutzername
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={config.username}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({ ...c, username: e.target.value }))
|
|
}
|
|
placeholder="user@example.com"
|
|
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
|
Passwort
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? "text" : "password"}
|
|
value={config.password}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({ ...c, password: e.target.value }))
|
|
}
|
|
placeholder="••••••••"
|
|
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 pr-16 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-neutral-500 hover:text-neutral-700"
|
|
>
|
|
{showPassword ? "Verbergen" : "Anzeigen"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
|
Kalender-Pfad
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={config.calendar_path}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({ ...c, calendar_path: e.target.value }))
|
|
}
|
|
placeholder="/dav/calendars/user/default/"
|
|
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
/>
|
|
<p className="mt-1 text-xs text-neutral-400">
|
|
Pfad zum Kalender auf dem CalDAV-Server
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sync Settings */}
|
|
<div className="flex flex-col gap-4 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<label className="flex items-center gap-2.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.sync_enabled}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({ ...c, sync_enabled: e.target.checked }))
|
|
}
|
|
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-400"
|
|
/>
|
|
<span className="text-sm font-medium text-neutral-700">
|
|
Automatische Synchronisierung
|
|
</span>
|
|
</label>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-neutral-500">Intervall:</label>
|
|
<select
|
|
value={config.sync_interval_minutes}
|
|
onChange={(e) =>
|
|
setConfig((c) => ({
|
|
...c,
|
|
sync_interval_minutes: Number(e.target.value),
|
|
}))
|
|
}
|
|
disabled={!config.sync_enabled}
|
|
className="rounded-md border border-neutral-200 px-2 py-1 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 disabled:opacity-50"
|
|
>
|
|
{SYNC_INTERVALS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 border-t border-neutral-200 pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={saveMutation.isPending}
|
|
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
|
>
|
|
{saveMutation.isPending ? "Speichern..." : "Speichern"}
|
|
</button>
|
|
|
|
{hasConfig && (
|
|
<button
|
|
type="button"
|
|
onClick={() => syncMutation.mutate()}
|
|
disabled={syncMutation.isPending}
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-4 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
|
>
|
|
<RefreshCw
|
|
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
|
/>
|
|
{syncMutation.isPending
|
|
? "Synchronisiere..."
|
|
: "Jetzt synchronisieren"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Sync Status */}
|
|
{syncStatus && syncStatus.last_sync_at !== null && (
|
|
<SyncStatusDisplay data={syncStatus} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SyncStatusDisplay({ data }: { data: CalDAVSyncResponse }) {
|
|
const hasErrors = data.sync?.errors && data.sync.errors.length > 0;
|
|
const lastSync = data.sync?.last_sync_at
|
|
? new Date(data.sync.last_sync_at)
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-lg border p-4 ${
|
|
hasErrors
|
|
? "border-red-200 bg-red-50"
|
|
: "border-emerald-200 bg-emerald-50"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{hasErrors ? (
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
|
|
) : (
|
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={`text-sm font-medium ${hasErrors ? "text-red-800" : "text-emerald-800"}`}
|
|
>
|
|
{hasErrors
|
|
? "Letzte Synchronisierung mit Fehlern"
|
|
: "Letzte Synchronisierung erfolgreich"}
|
|
</p>
|
|
|
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
{lastSync && (
|
|
<span className="inline-flex items-center gap-1 text-neutral-600">
|
|
<Clock className="h-3 w-3" />
|
|
{lastSync.toLocaleDateString("de-DE", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
})}{" "}
|
|
{lastSync.toLocaleTimeString("de-DE", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</span>
|
|
)}
|
|
<span className="inline-flex items-center gap-1 text-neutral-600">
|
|
<ArrowUpDown className="h-3 w-3" />
|
|
{data.sync.items_pushed} gesendet, {data.sync.items_pulled}{" "}
|
|
empfangen
|
|
</span>
|
|
{data.sync.sync_duration && (
|
|
<span className="text-neutral-400">
|
|
Dauer: {data.sync.sync_duration}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{hasErrors && (
|
|
<div className="mt-2 space-y-1">
|
|
{data.sync.errors!.map((err, i) => (
|
|
<p key={i} className="text-xs text-red-700">
|
|
{err}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|