Files
myeasycms-v2/packages/features/verbandsverwaltung/src/components/cross-org-member-search.tsx
Zaid Marzguioui b26e5aaafa
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m20s
Workflow / ⚫️ Test (push) Has been skipped
feat: pre-existing local changes — fischerei, verband, modules, members, packages
Commits all remaining uncommitted local work:

- apps/web: fischerei, verband, modules, members-cms, documents,
  newsletter, meetings, site-builder, courses, bookings, events,
  finance pages and components
- apps/web: marketing page updates, layout, paths config,
  next.config.mjs, styles/makerkit.css
- apps/web/i18n: documents, fischerei, marketing, verband (de+en)
- packages/features: finance, fischerei, member-management,
  module-builder, newsletter, sitzungsprotokolle, verbandsverwaltung
  server APIs and components
- packages/ui: button.tsx updates
- pnpm-lock.yaml
2026-04-02 01:19:54 +02:00

606 lines
22 KiB
TypeScript

'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 { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
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?.data) setPreview(data.data);
setPreviewLoading(false);
},
onError: () => {
setPreviewLoading(false);
},
});
const { execute: executeTransfer, isPending: isTransferring } =
useActionWithToast(transferMember, {
successMessage: 'Mitglied erfolgreich transferiert',
errorMessage: 'Fehler beim Transfer',
onSuccess: () => {
setTransferTarget(null);
setTargetAccountId('');
setTransferReason('');
setKeepSepa(true);
setPreview(null);
},
});
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 scope="col" className="p-3 text-left font-medium">
Name
</th>
<th scope="col" className="p-3 text-left font-medium">
Organisation
</th>
<th scope="col" className="p-3 text-left font-medium">
E-Mail
</th>
<th scope="col" className="p-3 text-left font-medium">
Ort
</th>
<th scope="col" className="p-3 text-center font-medium">
Status
</th>
<th scope="col" className="p-3 text-left font-medium">
Eintritt
</th>
<th scope="col" 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 &quot;ausstehend&quot; (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>
);
}