Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
113
components/admin/InviteManager.tsx
Normal file
113
components/admin/InviteManager.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Invite-Verwaltung (plan.md Abschnitt 3): Link erstellen, Liste mit
|
||||
* Status (Aktiv/Abgelaufen/Verwendet) und Zaehleruebersicht.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||
|
||||
export interface InviteRow {
|
||||
id: string;
|
||||
expiresAt: Date | null;
|
||||
usedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function InviteManager({
|
||||
calendarId,
|
||||
invites,
|
||||
}: {
|
||||
calendarId: string;
|
||||
invites: InviteRow[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
|
||||
|
||||
async function createInvite() {
|
||||
setBusy(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()) as { error?: string };
|
||||
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { inviteUrl: string };
|
||||
setLastInviteUrl(data.inviteUrl);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const active = invites.filter(
|
||||
(invite) =>
|
||||
invite.usedAt === null &&
|
||||
(invite.expiresAt === null || invite.expiresAt > now),
|
||||
).length;
|
||||
const expired = invites.filter(
|
||||
(invite) =>
|
||||
invite.usedAt === null &&
|
||||
invite.expiresAt !== null &&
|
||||
invite.expiresAt <= now,
|
||||
).length;
|
||||
const used = invites.filter((invite) => invite.usedAt !== null).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Einladungen</h2>
|
||||
<Button type="button" variant="secondary" disabled={busy} onClick={createInvite}>
|
||||
{busy ? 'Erstelle …' : '+ Benutzer einladen'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
{lastInviteUrl && (
|
||||
<div className="mb-4 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>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex gap-4 text-sm">
|
||||
<Badge variant="success">Aktiv: {active}</Badge>
|
||||
<Badge variant="warning">Abgelaufen: {expired}</Badge>
|
||||
<Badge variant="neutral">Verwendet: {used}</Badge>
|
||||
</div>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Einladungen erstellt.</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-sm text-slate-600">
|
||||
{invites.slice(0, 10).map((invite) => (
|
||||
<li key={invite.id} className="flex justify-between rounded bg-slate-50 px-3 py-1.5">
|
||||
<span>Erstellt {invite.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
<span>
|
||||
{invite.usedAt
|
||||
? 'Verwendet'
|
||||
: invite.expiresAt && invite.expiresAt <= now
|
||||
? 'Abgelaufen'
|
||||
: 'Aktiv'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user