feat: add CalDAV settings UI and team management pages (Phase 3P)
Backend: PUT /api/tenants/{id}/settings endpoint for updating tenant
settings (JSONB merge). Frontend: /einstellungen page with CalDAV
config (URL, credentials, calendar path, sync toggle, interval),
manual sync button, live sync status display. /einstellungen/team
page with member list, invite-by-email, role management.
This commit is contained in:
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal file
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"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>("/api/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>(`/api/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>("/api/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user