feat: add invitations management and import wizard; enhance audit logging and member detail fetching
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Mail, XCircle, Send } from 'lucide-react';
|
||||
|
||||
import {
|
||||
inviteMemberToPortal,
|
||||
revokePortalInvitation,
|
||||
} from '@kit/member-management/actions/member-actions';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
member_id: string;
|
||||
email: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
accepted_at: string | null;
|
||||
}
|
||||
|
||||
interface MemberOption {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface InvitationsViewProps {
|
||||
invitations: Invitation[];
|
||||
members: MemberOption[];
|
||||
accountId: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
accepted: 'Angenommen',
|
||||
revoked: 'Widerrufen',
|
||||
expired: 'Abgelaufen',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
'default' | 'secondary' | 'destructive' | 'outline'
|
||||
> = {
|
||||
pending: 'default',
|
||||
accepted: 'secondary',
|
||||
revoked: 'destructive',
|
||||
expired: 'outline',
|
||||
};
|
||||
|
||||
export function InvitationsView({
|
||||
invitations,
|
||||
members,
|
||||
accountId,
|
||||
account,
|
||||
}: InvitationsViewProps) {
|
||||
const router = useRouter();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [selectedMemberId, setSelectedMemberId] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { execute: executeInvite, isPending: isInviting } = useActionWithToast(
|
||||
inviteMemberToPortal,
|
||||
{
|
||||
successMessage: 'Einladung gesendet',
|
||||
onSuccess: () => {
|
||||
setShowDialog(false);
|
||||
setSelectedMemberId('');
|
||||
setEmail('');
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: executeRevoke, isPending: isRevoking } = useActionWithToast(
|
||||
revokePortalInvitation,
|
||||
{
|
||||
successMessage: 'Einladung widerrufen',
|
||||
onSuccess: () => router.refresh(),
|
||||
},
|
||||
);
|
||||
|
||||
const handleInvite = useCallback(() => {
|
||||
if (!selectedMemberId || !email) return;
|
||||
executeInvite({
|
||||
memberId: selectedMemberId,
|
||||
accountId,
|
||||
email,
|
||||
});
|
||||
}, [executeInvite, selectedMemberId, accountId, email]);
|
||||
|
||||
const handleRevoke = useCallback(
|
||||
(invitationId: string) => {
|
||||
if (!window.confirm('Einladung wirklich widerrufen?')) return;
|
||||
executeRevoke({ invitationId });
|
||||
},
|
||||
[executeRevoke],
|
||||
);
|
||||
|
||||
// When a member is selected, pre-fill email
|
||||
const handleMemberChange = useCallback(
|
||||
(memberId: string) => {
|
||||
setSelectedMemberId(memberId);
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (member?.email) {
|
||||
setEmail(member.email);
|
||||
}
|
||||
},
|
||||
[members],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowDialog(true)}
|
||||
data-test="invite-member-btn"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Einladung senden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Send Invitation Dialog */}
|
||||
{showDialog && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Einladung senden
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Mitglied
|
||||
</label>
|
||||
<select
|
||||
value={selectedMemberId}
|
||||
onChange={(e) => handleMemberChange(e.target.value)}
|
||||
data-test="invite-member-select"
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">— Mitglied auswählen —</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.last_name}, {m.first_name}
|
||||
{m.email ? ` (${m.email})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="E-Mail eingeben..."
|
||||
data-test="invite-email-input"
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
disabled={!selectedMemberId || !email || isInviting}
|
||||
data-test="invite-submit-btn"
|
||||
>
|
||||
{isInviting ? 'Sende...' : 'Einladung senden'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowDialog(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invitations Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Einladungen ({invitations.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invitations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<Mail className="text-muted-foreground mb-4 h-10 w-10" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
Keine Einladungen vorhanden
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Senden Sie die erste Einladung zum Mitgliederportal.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Erstellt</th>
|
||||
<th className="p-3 text-left font-medium">Läuft ab</th>
|
||||
<th className="p-3 text-left font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((inv) => (
|
||||
<tr key={inv.id} className="border-b">
|
||||
<td className="p-3">{inv.email}</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={STATUS_COLORS[inv.status] ?? 'secondary'}
|
||||
>
|
||||
{STATUS_LABELS[inv.status] ?? inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-xs">
|
||||
{formatDate(inv.created_at)}
|
||||
</td>
|
||||
<td className="p-3 text-xs">
|
||||
{formatDate(inv.expires_at)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{inv.status === 'pending' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isRevoking}
|
||||
onClick={() => handleRevoke(inv.id)}
|
||||
data-test={`revoke-invitation-${inv.id}`}
|
||||
>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Widerrufen
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { InvitationsView } from './invitations-view';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function InvitationsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const invitations = await api.listPortalInvitations(acct.id);
|
||||
|
||||
// Fetch members for the "send invitation" dialog
|
||||
const { data: members } = await client
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email')
|
||||
.eq('account_id', acct.id)
|
||||
.eq('status', 'active')
|
||||
.order('last_name');
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title="Portal-Einladungen"
|
||||
description="Einladungen zum Mitgliederportal verwalten"
|
||||
>
|
||||
<InvitationsView
|
||||
invitations={invitations}
|
||||
members={members ?? []}
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user