159 lines
5.3 KiB
TypeScript
159 lines
5.3 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Einladungs-Sektion fuer Modals: laedt die Einladungen eines
|
|
* Kalenders per GET /api/calendars/[id]/invites und ermoeglicht das
|
|
* Erstellen neuer Invite-Links (POST, gleicher Endpoint).
|
|
*
|
|
* Der Klartext-Link wird nur einmal direkt nach dem Erstellen
|
|
* angezeigt (in der DB liegt nur der Hash - plan.md Abschnitt 9).
|
|
* Wird als eigenstaendige Komponente gefuehrt, damit sie sowohl im
|
|
* Bearbeiten-Modal als auch kuenftig anderswo verwendet werden kann.
|
|
*/
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { Button, ErrorMessage, Badge } from '@/components/ui/ui';
|
|
|
|
/** Einladung, wie der GET-Endpoint sie liefert (Datumswerte als ISO). */
|
|
interface InviteApiRow {
|
|
id: string;
|
|
expiresAt: string | null;
|
|
usedAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export function InviteSection({ calendarId }: { calendarId: string }) {
|
|
const [invites, setInvites] = useState<InviteApiRow[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
|
|
|
|
/** Laedt die Einladungen des Kalenders. */
|
|
const loadInvites = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetch(`/api/calendars/${calendarId}/invites`);
|
|
if (!response.ok) {
|
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
|
setError(data.error ?? 'Einladungen konnten nicht geladen werden.');
|
|
return;
|
|
}
|
|
const data = (await response.json()) as { invites: InviteApiRow[] };
|
|
setInvites(data.invites);
|
|
} catch {
|
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [calendarId]);
|
|
|
|
useEffect(() => {
|
|
void loadInvites();
|
|
}, [loadInvites]);
|
|
|
|
/** Erstellt einen neuen Invite-Link und zeigt ihn einmalig an. */
|
|
async function createInvite() {
|
|
setIsCreating(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetch(`/api/calendars/${calendarId}/invites`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ calendarId }),
|
|
});
|
|
if (!response.ok) {
|
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
|
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
|
return;
|
|
}
|
|
const data = (await response.json()) as { inviteUrl: string };
|
|
setLastInviteUrl(data.inviteUrl);
|
|
await loadInvites();
|
|
} catch {
|
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
const active = invites.filter(
|
|
(invite) =>
|
|
invite.usedAt === null &&
|
|
(invite.expiresAt === null || new Date(invite.expiresAt) > now),
|
|
).length;
|
|
const expired = invites.filter(
|
|
(invite) =>
|
|
invite.usedAt === null &&
|
|
invite.expiresAt !== null &&
|
|
new Date(invite.expiresAt) <= now,
|
|
).length;
|
|
const used = invites.filter((invite) => invite.usedAt !== null).length;
|
|
|
|
return (
|
|
<section aria-label="Einladungen">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-base font-semibold">Einladungen</h3>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={isCreating}
|
|
onClick={createInvite}
|
|
>
|
|
{isCreating ? 'Erstelle …' : '+ Benutzer einladen'}
|
|
</Button>
|
|
</div>
|
|
|
|
<ErrorMessage message={error} />
|
|
|
|
{lastInviteUrl && (
|
|
<div className="mb-3 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200">
|
|
<p className="mb-1 font-medium">Neuer Invite-Link (einmalig anzeigen):</p>
|
|
<code className="break-all text-xs">{lastInviteUrl}</code>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && (
|
|
<div className="mb-3 flex gap-3">
|
|
<Badge variant="success">Aktiv: {active}</Badge>
|
|
<Badge variant="warning">Abgelaufen: {expired}</Badge>
|
|
<Badge variant="neutral">Verwendet: {used}</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<p className="text-sm text-slate-500">Einladungen werden geladen …</p>
|
|
) : invites.length === 0 ? (
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
Noch keine Einladungen erstellt.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-1 text-sm text-slate-600 dark:text-slate-300">
|
|
{invites.slice(0, 10).map((invite) => {
|
|
const isUsed = invite.usedAt !== null;
|
|
const isExpired =
|
|
!isUsed && invite.expiresAt !== null && new Date(invite.expiresAt) <= now;
|
|
return (
|
|
<li
|
|
key={invite.id}
|
|
className="flex justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
|
|
>
|
|
<span>
|
|
Erstellt {new Date(invite.createdAt).toLocaleDateString('de-DE')}
|
|
</span>
|
|
<span>
|
|
{isUsed
|
|
? 'Verwendet'
|
|
: isExpired
|
|
? 'Abgelaufen'
|
|
: 'Aktiv'}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
} |