Prepare Kalendartool for MPM and disable invites

This commit is contained in:
Kühn
2026-10-08 15:06:22 +02:00
parent bc49c3074e
commit 556202575e
50 changed files with 905 additions and 836 deletions

View File

@@ -9,6 +9,7 @@
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
*/
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -66,7 +67,7 @@ export function CalendarManager({
setIsPending(true);
setError(null);
try {
const response = await fetch('/api/calendars', {
const response = await appFetch('/api/calendars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),

View File

@@ -1,6 +1,7 @@
'use client';
/** Formular zum Erstellen eines Kalenders (nur Admin). */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -17,7 +18,7 @@ export function CreateCalendarForm() {
const formData = new FormData(event.currentTarget);
try {
const response = await fetch('/api/calendars', {
const response = await appFetch('/api/calendars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -1,6 +1,7 @@
'use client';
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -29,7 +30,7 @@ export function EditCalendarForm({
const formData = new FormData(event.currentTarget);
try {
const response = await fetch(`/api/calendars/${calendarId}`, {
const response = await appFetch(`/api/calendars/${calendarId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -15,6 +15,7 @@
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
*/
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -61,7 +62,7 @@ export function EditCalendarModal({
const formData = new FormData(event.currentTarget);
try {
const response = await fetch(`/api/calendars/${calendar.id}`, {
const response = await appFetch(`/api/calendars/${calendar.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -1,113 +0,0 @@
'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>
);
}

View File

@@ -1,159 +0,0 @@
'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>
);
}

View File

@@ -1,16 +1,17 @@
'use client';
/**
* Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem).
* Mitgliederverwaltung fuer Kalender.
*
* Statt Invite-Links werden bestehende Benutzer direkt per E-Mail
* ueber die Datenbank gesucht und dem Kalender zugeordnet:
* 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';
@@ -44,7 +45,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/members`);
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.');
@@ -75,7 +76,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setAddingEmail(email);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/members`, {
const response = await appFetch(`/api/calendars/${calendarId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
@@ -100,7 +101,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setRemovingId(memberId);
setError(null);
try {
const response = await fetch(
const response = await appFetch(
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
{ method: 'DELETE' },
);
@@ -130,7 +131,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
searchTimer.current = setTimeout(async () => {
setIsSearching(true);
try {
const response = await fetch(
const response = await appFetch(
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
);
if (response.ok) {
@@ -237,4 +238,4 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
)}
</section>
);
}
}

View File

@@ -6,6 +6,7 @@
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
* (Server lehnt ab).
*/
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell';
@@ -32,7 +33,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/users', { cache: 'no-store' });
const response = await appFetch('/api/users', { cache: 'no-store' });
if (!response.ok) {
const data = (await response.json().catch(() => null)) as
| { error?: string }
@@ -65,7 +66,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
setPendingId(userId);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
const response = await appFetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }),