Prevents "M.forEach is not a function" crashes when API returns error objects or unexpected shapes instead of arrays. Guards all useQuery consumers with Array.isArray checks and safe defaults for object props. Files fixed: DeadlineList, AppointmentList, TenantSwitcher, DeadlineTrafficLights, UpcomingTimeline, CaseOverviewGrid, AISummaryCard, TeamSettings, and all page-level components (dashboard, cases, fristen, termine, ai/extract).
168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
"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[]>(`/tenants/${tenantId}/members`),
|
|
enabled: !!tenantId,
|
|
});
|
|
|
|
const inviteMutation = useMutation({
|
|
mutationFn: (data: { email: string; role: string }) =>
|
|
api.post(`/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(`/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 */}
|
|
{Array.isArray(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>
|
|
);
|
|
}
|