feat: add cross-organization member search and template cloning functionality
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { ArrowRightLeft, Search, Users } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import {
|
||||
getTransferPreview,
|
||||
transferMember,
|
||||
} from '../server/actions/hierarchy-actions';
|
||||
import type { CrossOrgMember, TransferPreview } from '../server/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
pending: 'Ausstehend',
|
||||
resigned: 'Ausgetreten',
|
||||
excluded: 'Ausgeschlossen',
|
||||
deceased: 'Verstorben',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'default',
|
||||
inactive: 'secondary',
|
||||
pending: 'outline',
|
||||
resigned: 'secondary',
|
||||
excluded: 'destructive',
|
||||
deceased: 'secondary',
|
||||
};
|
||||
|
||||
interface CrossOrgMemberSearchProps {
|
||||
account: string;
|
||||
members: CrossOrgMember[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
childAccounts: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function CrossOrgMemberSearch({
|
||||
account,
|
||||
members,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
childAccounts,
|
||||
}: CrossOrgMemberSearchProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [transferTarget, setTransferTarget] = useState<CrossOrgMember | null>(
|
||||
null,
|
||||
);
|
||||
const [targetAccountId, setTargetAccountId] = useState('');
|
||||
const [transferReason, setTransferReason] = useState('');
|
||||
const [keepSepa, setKeepSepa] = useState(true);
|
||||
const [preview, setPreview] = useState<TransferPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const currentSearch = searchParams.get('q') ?? '';
|
||||
const currentStatus = searchParams.get('status') ?? '';
|
||||
const currentAccount = searchParams.get('accountId') ?? '';
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { search: currentSearch },
|
||||
});
|
||||
|
||||
const { execute: executePreview } = useAction(getTransferPreview, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data) setPreview(data);
|
||||
setPreviewLoading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setPreviewLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeTransfer, isPending: isTransferring } = useAction(
|
||||
transferMember,
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Mitglied erfolgreich transferiert');
|
||||
setTransferTarget(null);
|
||||
setTargetAccountId('');
|
||||
setTransferReason('');
|
||||
setKeepSepa(true);
|
||||
setPreview(null);
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Transfer');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const buildUrl = useCallback(
|
||||
(params: Record<string, string | number | null>) => {
|
||||
const urlSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val === null || val === '' || val === undefined) {
|
||||
urlSearchParams.delete(key);
|
||||
} else {
|
||||
urlSearchParams.set(key, String(val));
|
||||
}
|
||||
}
|
||||
|
||||
return `/home/${account}/verband/members?${urlSearchParams.toString()}`;
|
||||
},
|
||||
[account, searchParams],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(values: { search: string }) => {
|
||||
router.push(buildUrl({ q: values.search || null, page: null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
router.push(buildUrl({ status: e.target.value || null, page: null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
const handleAccountChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
router.push(buildUrl({ accountId: e.target.value || null, page: null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
router.push(buildUrl({ page: newPage > 1 ? newPage : null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
function handleTransferConfirm() {
|
||||
if (!transferTarget || !targetAccountId) return;
|
||||
|
||||
executeTransfer({
|
||||
memberId: transferTarget.id,
|
||||
targetAccountId,
|
||||
reason: transferReason || undefined,
|
||||
keepSepa,
|
||||
});
|
||||
}
|
||||
|
||||
// Accounts available as transfer targets (exclude member's current account)
|
||||
const transferTargetAccounts = transferTarget
|
||||
? childAccounts.filter((a) => a.id !== transferTarget.account_id)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search + Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSearch)}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Input
|
||||
placeholder="Name, E-Mail oder Mitgliedsnr. suchen..."
|
||||
{...form.register('search')}
|
||||
data-test="cross-org-search-input"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
data-test="cross-org-search-btn"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Suchen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={currentStatus}
|
||||
onChange={handleStatusChange}
|
||||
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
data-test="cross-org-status-filter"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{childAccounts.length > 0 && (
|
||||
<select
|
||||
value={currentAccount}
|
||||
onChange={handleAccountChange}
|
||||
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
data-test="cross-org-account-filter"
|
||||
>
|
||||
<option value="">Alle Organisationen</option>
|
||||
{childAccounts.map((childAccount) => (
|
||||
<option key={childAccount.id} value={childAccount.id}>
|
||||
{childAccount.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4" />
|
||||
Mitglieder ({total})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{members.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Keine Mitglieder gefunden
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
{currentSearch
|
||||
? 'Versuchen Sie einen anderen Suchbegriff.'
|
||||
: 'In den verknüpften Organisationen sind noch keine Mitglieder vorhanden.'}
|
||||
</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">Name</th>
|
||||
<th className="p-3 text-left font-medium">Organisation</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-center font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Eintritt</th>
|
||||
<th className="p-3 text-right font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.id} className="hover:bg-muted/30 border-b">
|
||||
<td className="p-3 font-medium">
|
||||
{member.last_name}, {member.first_name}
|
||||
{member.member_number && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
#{member.member_number}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{member.account_slug ? (
|
||||
<Link
|
||||
href={`/home/${member.account_slug}/members-cms`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{member.account_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{member.account_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{member.email ?? '—'}
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{member.city ?? '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(STATUS_COLORS[member.status] ?? 'outline') as
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
}
|
||||
>
|
||||
{STATUS_LABELS[member.status] ?? member.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{formatDate(member.entry_date)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTransferTarget(member);
|
||||
setTargetAccountId('');
|
||||
setTransferReason('');
|
||||
setPreview(null);
|
||||
setPreviewLoading(true);
|
||||
executePreview({ memberId: member.id });
|
||||
}}
|
||||
title="Mitglied transferieren"
|
||||
data-test="transfer-member-btn"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages} ({total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transfer Dialog */}
|
||||
<Dialog
|
||||
open={!!transferTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTransferTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mitglied transferieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
{transferTarget && (
|
||||
<>
|
||||
<strong>
|
||||
{transferTarget.first_name} {transferTarget.last_name}
|
||||
</strong>{' '}
|
||||
wird von <strong>{transferTarget.account_name}</strong> in
|
||||
eine andere Organisation verschoben.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Preview: side effects */}
|
||||
{previewLoading && (
|
||||
<div className="bg-muted/50 animate-pulse rounded-md p-4 text-center text-sm">
|
||||
Lade Transfervorschau...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !previewLoading && (
|
||||
<div className="space-y-3">
|
||||
{/* Active courses */}
|
||||
{preview.courses.length > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm dark:border-blue-800 dark:bg-blue-950">
|
||||
<p className="mb-1 font-medium text-blue-800 dark:text-blue-200">
|
||||
{preview.courses.length} aktive Kurseinschreibung(en)
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-blue-700 dark:text-blue-300">
|
||||
{preview.courses.map((course) => (
|
||||
<li key={course.id}>
|
||||
{course.name} ({course.accountName})
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
bleibt erhalten
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open invoices */}
|
||||
{preview.invoices.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="mb-1 font-medium text-amber-800 dark:text-amber-200">
|
||||
{preview.invoices.length} offene Rechnung(en)
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
{preview.invoices.map((inv) => (
|
||||
<li key={inv.id}>
|
||||
{inv.invoiceNumber} — {inv.amount.toFixed(2)} EUR (
|
||||
{inv.accountName})
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
verbleibt beim Quellverein
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEPA mandates */}
|
||||
{preview.mandates.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="mb-1 font-medium text-amber-800 dark:text-amber-200">
|
||||
{preview.mandates.length} aktive(s) SEPA-Mandat(e)
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
{preview.mandates.map((mandate) => (
|
||||
<li key={mandate.id}>
|
||||
{mandate.reference} (Status: {mandate.status})
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="ml-2 text-[10px]"
|
||||
>
|
||||
wird zurückgesetzt
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletters */}
|
||||
{preview.newsletters.length > 0 && (
|
||||
<div className="bg-muted/50 rounded-md border p-3 text-sm">
|
||||
<p className="mb-1 font-medium">
|
||||
{preview.newsletters.length} Newsletter-Abonnement(s)
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-xs">
|
||||
{preview.newsletters.map((newsletter) => (
|
||||
<li key={newsletter.id}>
|
||||
{newsletter.name} ({newsletter.accountName})
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
bleibt erhalten
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What gets cleared */}
|
||||
<div className="bg-muted/50 rounded-md p-3 text-sm">
|
||||
<p className="mb-1 font-medium">Wird zurückgesetzt:</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-xs">
|
||||
{preview.member.memberNumber && (
|
||||
<li>
|
||||
Mitgliedsnr.{' '}
|
||||
<strong>#{preview.member.memberNumber}</strong> —
|
||||
Neuvergabe im Zielverein nötig
|
||||
</li>
|
||||
)}
|
||||
{preview.member.hasDuesCategory && (
|
||||
<li>
|
||||
Beitragskategorie — muss im Zielverein neu zugewiesen
|
||||
werden
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
SEPA-Mandatstatus → "ausstehend" (Neubestätigung
|
||||
nötig)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* No side effects */}
|
||||
{preview.courses.length === 0 &&
|
||||
preview.invoices.length === 0 &&
|
||||
preview.mandates.length === 0 &&
|
||||
preview.newsletters.length === 0 && (
|
||||
<div className="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-800 dark:bg-green-950">
|
||||
<p className="font-medium text-green-800 dark:text-green-200">
|
||||
Keine aktiven Verknüpfungen gefunden
|
||||
</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300">
|
||||
Transfer kann ohne Seiteneffekte durchgeführt werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="transfer-target" className="text-sm font-medium">
|
||||
Zielorganisation
|
||||
</label>
|
||||
<select
|
||||
id="transfer-target"
|
||||
value={targetAccountId}
|
||||
onChange={(e) => setTargetAccountId(e.target.value)}
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
data-test="transfer-target-select"
|
||||
>
|
||||
<option value="">Organisation auswählen...</option>
|
||||
{transferTargetAccounts.map((targetAccount) => (
|
||||
<option key={targetAccount.id} value={targetAccount.id}>
|
||||
{targetAccount.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepSepa}
|
||||
onChange={(e) => setKeepSepa(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
data-test="transfer-keep-sepa"
|
||||
/>
|
||||
SEPA-Bankdaten (IBAN/BIC) übernehmen
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Bankverbindung wird übernommen, Mandat muss im Zielverein neu
|
||||
bestätigt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="transfer-reason" className="text-sm font-medium">
|
||||
Grund (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="transfer-reason"
|
||||
value={transferReason}
|
||||
onChange={(e) => setTransferReason(e.target.value)}
|
||||
placeholder="z.B. Umzug, Vereinswechsel..."
|
||||
rows={2}
|
||||
data-test="transfer-reason-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTransferTarget(null)}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTransferConfirm}
|
||||
disabled={!targetAccountId || isTransferring}
|
||||
data-test="transfer-confirm-btn"
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
||||
{isTransferring ? 'Wird transferiert...' : 'Transferieren'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user