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:
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal file
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Settings, Calendar, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Tenant } from "@/lib/types";
|
||||
import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const {
|
||||
data: tenant,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tenant-current", tenantId],
|
||||
queryFn: () => api.get<Tenant>(`/api/tenants/${tenantId}`),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Einstellungen
|
||||
</h1>
|
||||
<Link
|
||||
href="/einstellungen/team"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Team verwalten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tenant Info */}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon={Settings}
|
||||
title="Fehler beim Laden"
|
||||
description="Einstellungen konnten nicht geladen werden."
|
||||
action={
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : tenant ? (
|
||||
<>
|
||||
{/* Kanzlei Info */}
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
|
||||
<Settings className="h-4 w-4 text-neutral-500" />
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
Kanzlei
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Name</p>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{tenant.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Slug</p>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{tenant.slug}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Erstellt am</p>
|
||||
<p className="text-sm text-neutral-700">
|
||||
{new Date(tenant.created_at).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CalDAV Settings */}
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
|
||||
<Calendar className="h-4 w-4 text-neutral-500" />
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
CalDAV-Synchronisierung
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CalDAVSettings tenant={tenant} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal file
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { TeamSettings } from "@/components/settings/TeamSettings";
|
||||
|
||||
export default function TeamPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/einstellungen"
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Users className="h-4 w-4 text-neutral-500" />
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Team verwalten
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="border-b border-neutral-100 pb-3">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
Mitglieder
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">
|
||||
Benutzer einladen und Rollen verwalten
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TeamSettings />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/settings/TeamSettings.tsx
Normal file
167
frontend/src/components/settings/TeamSettings.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { UserTenant } from "@/lib/types";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
|
||||
owner: { label: "Eigentümer", icon: Crown },
|
||||
admin: { label: "Administrator", icon: Shield },
|
||||
member: { label: "Mitglied", icon: User },
|
||||
};
|
||||
|
||||
export function TeamSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState("member");
|
||||
|
||||
const {
|
||||
data: members,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["tenant-members", tenantId],
|
||||
queryFn: () =>
|
||||
api.get<UserTenant[]>(`/api/tenants/${tenantId}/members`),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: { email: string; role: string }) =>
|
||||
api.post(`/api/tenants/${tenantId}/invite`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
toast.success("Benutzer eingeladen");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Einladen");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
api.delete(`/api/tenants/${tenantId}/members/${userId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
toast.success("Mitglied entfernt");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Entfernen");
|
||||
},
|
||||
});
|
||||
|
||||
const handleInvite = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
inviteMutation.mutate({ email: email.trim(), role });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Fehler beim Laden"
|
||||
description="Team-Mitglieder konnten nicht geladen werden."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invite Form */}
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending || !email.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Members List */}
|
||||
{members && members.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||
{members.map((member, i) => {
|
||||
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
|
||||
const RoleIcon = roleInfo.icon;
|
||||
return (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
i < members.length - 1 ? "border-b border-neutral-100" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100">
|
||||
<RoleIcon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{member.user_id.slice(0, 8)}...
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== "owner" && (
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Noch keine Mitglieder"
|
||||
description="Laden Sie Teammitglieder per E-Mail ein."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,30 @@ export interface CalculateResponse {
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface CalDAVConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
calendar_path: string;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_minutes: number;
|
||||
}
|
||||
|
||||
export interface CalDAVSyncStatus {
|
||||
tenant_id: string;
|
||||
last_sync_at: string;
|
||||
items_pushed: number;
|
||||
items_pulled: number;
|
||||
errors?: string[];
|
||||
sync_duration: string;
|
||||
}
|
||||
|
||||
export interface CalDAVSyncResponse {
|
||||
status: string;
|
||||
sync: CalDAVSyncStatus;
|
||||
last_sync_at?: null;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
status: number;
|
||||
|
||||
Reference in New Issue
Block a user