Files
kalendartool/components/admin/MemberSection.tsx

242 lines
8.8 KiB
TypeScript

'use client';
/**
* Mitgliederverwaltung fuer Kalender.
*
* Vorhandene Benutzer werden direkt per E-Mail ueber die Datenbank
* gesucht und dem Kalender zugeordnet:
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
*
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
*/
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ErrorMessage } from '@/components/ui/ui';
/** Mitglied, wie der Members-Endpoint es liefert. */
interface MemberRow {
id: string;
createdAt: string;
user: { id: string; email: string; username: string | null };
}
/** Treffer der Benutzer-Suche. */
interface UserSuggestion {
id: string;
email: string;
username: string | null;
}
export function MemberSection({ calendarId }: { calendarId: string }) {
const [members, setMembers] = useState<MemberRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [addingEmail, setAddingEmail] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Laedt die aktuelle Mitgliederliste. */
const loadMembers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await appFetch(`/api/calendars/${calendarId}/members`);
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
return;
}
const data = (await response.json()) as { members: MemberRow[] };
setMembers(data.members);
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsLoading(false);
}
}, [calendarId]);
useEffect(() => {
void loadMembers();
}, [loadMembers]);
/** Entfernt die Suchvorschlaege, wenn das Suchfeld den Fokus verliert.
* Die kleine Verzoegerung stellt sicher, dass Klicks auf Vorschlaege
* (die nach dem Blur-Event kommen) noch verarbeitet werden. */
function hideSuggestionsDelayed() {
window.setTimeout(() => setSuggestions([]), 150);
}
/** Fuegt einen Benutzer per E-Mail zum Kalender hinzu. */
async function addMember(email: string) {
setAddingEmail(email);
setError(null);
try {
const response = await appFetch(`/api/calendars/${calendarId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Hinzufuegen fehlgeschlagen.');
return;
}
setQuery('');
setSuggestions([]);
await loadMembers();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setAddingEmail(null);
}
}
/** Entfernt ein Mitglied aus dem Kalender. */
async function removeMember(memberId: string) {
setRemovingId(memberId);
setError(null);
try {
const response = await appFetch(
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Entfernen fehlgeschlagen.');
return;
}
await loadMembers();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setRemovingId(null);
}
}
/** Live-Suche mit 300 ms Debounce (schont die Datenbank). */
function handleQueryChange(nextQuery: string) {
setQuery(nextQuery);
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
if (nextQuery.trim().length < 2) {
setSuggestions([]);
return;
}
searchTimer.current = setTimeout(async () => {
setIsSearching(true);
try {
const response = await appFetch(
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
);
if (response.ok) {
const data = (await response.json()) as { users: UserSuggestion[] };
setSuggestions(data.users);
}
} catch {
// Suche ist optional; Fehler still ignorieren, Vorschlaege bleiben leer.
} finally {
setIsSearching(false);
}
}, 300);
}
return (
<section aria-label="Mitglieder">
<h3 className="mb-3 text-base font-semibold">
Mitglieder ({members.length})
</h3>
<ErrorMessage message={error} />
{/* Suchfeld mit DB-Anbindung (Live-Vorschlaege). */}
<div className="relative mb-3">
<input
type="search"
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
onBlur={hideSuggestionsDelayed}
placeholder="Benutzer per E-Mail suchen und hinzufuegen …"
aria-label="Benutzer suchen"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
{(suggestions.length > 0 || isSearching) && query.trim().length >= 2 && (
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
{isSearching && (
<li className="px-3 py-2 text-sm text-slate-500">Suche …</li>
)}
{suggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => addMember(suggestion.email)}
disabled={addingEmail !== null}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-slate-100 disabled:opacity-50 dark:hover:bg-slate-700"
>
<span>
{suggestion.username ?? suggestion.email}
{suggestion.username && (
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
{suggestion.email}
</span>
)}
</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{addingEmail === suggestion.email ? 'Fuege hinzu …' : '+ Hinzufuegen'}
</span>
</button>
</li>
))}
{!isSearching && suggestions.length === 0 && (
<li className="px-3 py-2 text-sm text-slate-500">
Kein Benutzer gefunden.
</li>
)}
</ul>
)}
</div>
{isLoading ? (
<p className="text-sm text-slate-500">Mitglieder werden geladen …</p>
) : members.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Noch keine Mitglieder. Suche oben nach Benutzern, um sie hinzuzufuegen.
</p>
) : (
<ul className="space-y-1 text-sm">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
>
<span className="text-slate-700 dark:text-slate-200">
{member.user.username ?? member.user.email}
{member.user.username && (
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
{member.user.email}
</span>
)}
</span>
<button
type="button"
onClick={() => removeMember(member.id)}
disabled={removingId === member.id}
aria-label={`Mitglied ${member.user.username ?? member.user.email} entfernen`}
className="text-xs font-medium text-slate-500 transition-colors hover:text-red-600 disabled:opacity-50 dark:text-slate-400 dark:hover:text-red-400"
>
{removingId === member.id ? 'Entferne …' : 'Entfernen'}
</button>
</li>
))}
</ul>
)}
</section>
);
}