feat: add cross-organization member search and template cloning functionality
This commit is contained in:
@@ -142,6 +142,7 @@ export function ClubContactsManager({
|
||||
<CardTitle className="text-base">Ansprechpartner</CardTitle>
|
||||
{!showForm && (
|
||||
<Button
|
||||
data-test="contact-add-btn"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
@@ -240,6 +241,7 @@ export function ClubContactsManager({
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
data-test="contact-cancel-btn"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -249,7 +251,11 @@ export function ClubContactsManager({
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || isUpdating}>
|
||||
<Button
|
||||
data-test="contact-submit-btn"
|
||||
type="submit"
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
{isCreating || isUpdating
|
||||
? 'Wird gespeichert...'
|
||||
: editingId
|
||||
@@ -301,6 +307,7 @@ export function ClubContactsManager({
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
data-test="contact-edit-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(contact)}
|
||||
@@ -308,6 +315,7 @@ export function ClubContactsManager({
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
data-test="contact-delete-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
|
||||
@@ -93,30 +93,38 @@ export function ClubsDataTable({
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<Input
|
||||
data-test="clubs-search-input"
|
||||
placeholder="Verein suchen..."
|
||||
className="w-64"
|
||||
{...form.register('search')}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<Button
|
||||
data-test="clubs-search-btn"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Suchen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
data-test="clubs-type-filter"
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{types.map((associationType) => (
|
||||
<option key={associationType.id} value={associationType.id}>
|
||||
{associationType.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
data-test="clubs-archive-toggle"
|
||||
variant={showArchived ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleArchivedToggle}
|
||||
@@ -125,7 +133,7 @@ export function ClubsDataTable({
|
||||
</Button>
|
||||
|
||||
<Link href={`/home/${account}/verband/clubs/new`}>
|
||||
<Button size="sm">
|
||||
<Button data-test="clubs-new-btn" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Verein
|
||||
</Button>
|
||||
|
||||
@@ -128,9 +128,12 @@ export function CreateClubForm({
|
||||
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Kein Typ —</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{types.map((associationType) => (
|
||||
<option
|
||||
key={associationType.id}
|
||||
value={associationType.id}
|
||||
>
|
||||
{associationType.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { CalendarDays, Share2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { formatCurrencyAmount } from '@kit/shared/formatters';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
|
||||
interface HierarchyEvent {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
event_date: string;
|
||||
event_time: string | null;
|
||||
end_date: string | null;
|
||||
location: string | null;
|
||||
capacity: number | null;
|
||||
fee: number;
|
||||
status: string;
|
||||
registration_deadline: string | null;
|
||||
registration_count: number;
|
||||
shared_with_hierarchy: boolean;
|
||||
}
|
||||
|
||||
interface HierarchyEventsProps {
|
||||
account: string;
|
||||
events: HierarchyEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
open: 'Offen',
|
||||
full: 'Ausgebucht',
|
||||
running: 'Laufend',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
planned: 'outline',
|
||||
open: 'default',
|
||||
full: 'secondary',
|
||||
running: 'default',
|
||||
completed: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
export function HierarchyEvents({
|
||||
account,
|
||||
events,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}: HierarchyEventsProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentStatus = searchParams.get('status') ?? '';
|
||||
const currentSharedOnly = searchParams.get('sharedOnly') === 'true';
|
||||
const currentFromDate = searchParams.get('fromDate') ?? '';
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { fromDate: currentFromDate },
|
||||
});
|
||||
|
||||
const buildUrl = useCallback(
|
||||
(params: Record<string, string | number | boolean | null>) => {
|
||||
const urlSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val === null || val === '' || val === undefined || val === false) {
|
||||
urlSearchParams.delete(key);
|
||||
} else {
|
||||
urlSearchParams.set(key, String(val));
|
||||
}
|
||||
}
|
||||
|
||||
return `/home/${account}/verband/events?${urlSearchParams.toString()}`;
|
||||
},
|
||||
[account, searchParams],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
router.push(buildUrl({ status: e.target.value || null, page: null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
const handleSharedToggle = useCallback(() => {
|
||||
router.push(
|
||||
buildUrl({
|
||||
sharedOnly: !currentSharedOnly || null,
|
||||
page: null,
|
||||
}),
|
||||
);
|
||||
}, [router, buildUrl, currentSharedOnly]);
|
||||
|
||||
const handleDateFilter = useCallback(
|
||||
(values: { fromDate: string }) => {
|
||||
router.push(buildUrl({ fromDate: values.fromDate || null, page: null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
router.push(buildUrl({ page: newPage > 1 ? newPage : null }));
|
||||
},
|
||||
[router, buildUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleDateFilter)}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Input
|
||||
type="date"
|
||||
{...form.register('fromDate')}
|
||||
data-test="hierarchy-events-date-filter"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
data-test="hierarchy-events-date-btn"
|
||||
>
|
||||
Filtern
|
||||
</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="hierarchy-events-status-filter"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant={currentSharedOnly ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleSharedToggle}
|
||||
className="h-9"
|
||||
data-test="hierarchy-events-shared-toggle"
|
||||
>
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Nur geteilte
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Veranstaltungen ({total})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{events.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 Veranstaltungen gefunden
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
{currentStatus || currentSharedOnly || currentFromDate
|
||||
? 'Versuchen Sie andere Filterkriterien.'
|
||||
: 'In den verknüpften Organisationen sind noch keine Veranstaltungen 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">Veranstaltung</th>
|
||||
<th className="p-3 text-left font-medium">Organisation</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-center font-medium">Kapazität</th>
|
||||
<th className="p-3 text-right font-medium">Gebühr</th>
|
||||
<th className="p-3 text-center font-medium">Status</th>
|
||||
<th className="p-3 text-center font-medium">Geteilt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((evt) => (
|
||||
<tr key={evt.id} className="hover:bg-muted/30 border-b">
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/verband/events`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{evt.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">{evt.account_name}</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{formatDate(evt.event_date)}
|
||||
{evt.event_time && (
|
||||
<span className="ml-1 text-xs">{evt.event_time}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{evt.location ?? '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<span
|
||||
className={
|
||||
evt.capacity &&
|
||||
evt.registration_count >= evt.capacity
|
||||
? 'font-semibold text-red-600'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{evt.registration_count}
|
||||
</span>
|
||||
{evt.capacity != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{evt.capacity}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrencyAmount(evt.fee)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(STATUS_COLORS[evt.status] ?? 'outline') as
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
}
|
||||
>
|
||||
{STATUS_LABELS[evt.status] ?? evt.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
{evt.shared_with_hierarchy && (
|
||||
<Badge variant="outline">
|
||||
<Share2 className="mr-1 h-3 w-3" />
|
||||
Geteilt
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
CalendarDays,
|
||||
BookOpen,
|
||||
Euro,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { formatNumber, formatCurrencyAmount } from '@kit/shared/formatters';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface HierarchyReportProps {
|
||||
summary: {
|
||||
total_orgs: number;
|
||||
total_active_members: number;
|
||||
total_members: number;
|
||||
new_members_this_year: number;
|
||||
total_upcoming_events: number;
|
||||
total_active_courses: number;
|
||||
total_open_invoices: number;
|
||||
total_open_invoice_amount: number;
|
||||
total_sepa_batches_this_year: number;
|
||||
};
|
||||
report: Array<{
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
org_slug: string | null;
|
||||
depth: number;
|
||||
active_members: number;
|
||||
inactive_members: number;
|
||||
total_members: number;
|
||||
new_members_this_year: number;
|
||||
active_courses: number;
|
||||
upcoming_events: number;
|
||||
open_invoices: number;
|
||||
open_invoice_amount: number;
|
||||
sepa_batches_this_year: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function getDepthLabel(depth: number) {
|
||||
switch (depth) {
|
||||
case 0:
|
||||
return 'Verband';
|
||||
case 1:
|
||||
return 'Unterverband';
|
||||
default:
|
||||
return 'Verein';
|
||||
}
|
||||
}
|
||||
|
||||
function getDepthVariant(depth: number) {
|
||||
switch (depth) {
|
||||
case 0:
|
||||
return 'default' as const;
|
||||
case 1:
|
||||
return 'secondary' as const;
|
||||
default:
|
||||
return 'outline' as const;
|
||||
}
|
||||
}
|
||||
|
||||
export function HierarchyReport({ summary, report }: HierarchyReportProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Organisationen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(summary.total_orgs)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Aktive Mitglieder
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(summary.total_active_members)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
von {formatNumber(summary.total_members)} gesamt
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Neue Mitglieder (Jahr)
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(summary.new_members_this_year)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Anstehende Termine
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(summary.total_upcoming_events)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Aktive Kurse
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(summary.total_active_courses)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Offene Rechnungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrencyAmount(summary.total_open_invoice_amount)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatNumber(summary.total_open_invoices)} Rechnungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Euro className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Per-Org Report Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bericht pro Organisation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.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 Organisationen vorhanden
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Die Hierarchie enthält noch keine Organisationen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto 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">Ebene</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Aktive Mitgl.
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
||||
<th className="p-3 text-right font-medium">Neu (Jahr)</th>
|
||||
<th className="p-3 text-right font-medium">Kurse</th>
|
||||
<th className="p-3 text-right font-medium">Termine</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Offene Rechn.
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Offener Betrag
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.map((row) => (
|
||||
<tr key={row.org_id} className="hover:bg-muted/30 border-b">
|
||||
<td className="p-3 font-medium">
|
||||
<span style={{ paddingLeft: `${row.depth * 1.25}rem` }}>
|
||||
{row.org_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={getDepthVariant(row.depth)}>
|
||||
{getDepthLabel(row.depth)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.active_members)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.total_members)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.new_members_this_year)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.active_courses)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.upcoming_events)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatNumber(row.open_invoices)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrencyAmount(row.open_invoice_amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ function TreeNodeRow({
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
data-test="hierarchy-node-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -108,6 +109,7 @@ function TreeNodeRow({
|
||||
|
||||
{!isRoot && (
|
||||
<Button
|
||||
data-test="hierarchy-unlink-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
@@ -260,20 +262,27 @@ export function HierarchyTree({
|
||||
</label>
|
||||
<select
|
||||
id="link-account"
|
||||
data-test="hierarchy-link-select"
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Organisation auswählen...</option>
|
||||
{availableAccounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{a.slug ? ` (/${a.slug})` : ''}
|
||||
{availableAccounts.map((availableAccount) => (
|
||||
<option
|
||||
key={availableAccount.id}
|
||||
value={availableAccount.id}
|
||||
>
|
||||
{availableAccount.name}
|
||||
{availableAccount.slug
|
||||
? ` (/${availableAccount.slug})`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
data-test="hierarchy-link-btn"
|
||||
onClick={handleLink}
|
||||
disabled={!selectedAccountId || isLinking}
|
||||
>
|
||||
|
||||
@@ -6,3 +6,7 @@ export { ClubContactsManager } from './club-contacts-manager';
|
||||
export { ClubFeeBillingTable } from './club-fee-billing-table';
|
||||
export { ClubNotesList } from './club-notes-list';
|
||||
export { HierarchyTree } from './hierarchy-tree';
|
||||
export { CrossOrgMemberSearch } from './cross-org-member-search';
|
||||
export { HierarchyEvents } from './hierarchy-events';
|
||||
export { HierarchyReport } from './hierarchy-report';
|
||||
export { SharedTemplates } from './shared-templates';
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Copy, FileText, Mail } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
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 { cloneTemplate } from '../server/actions/hierarchy-actions';
|
||||
|
||||
interface SharedTemplate {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
template_source: 'newsletter' | 'document';
|
||||
name: string;
|
||||
description: string | null;
|
||||
template_type: string;
|
||||
shared_with_hierarchy: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SharedTemplatesProps {
|
||||
accountId: string;
|
||||
templates: SharedTemplate[];
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'newsletter' | 'document';
|
||||
|
||||
export function SharedTemplates({
|
||||
accountId,
|
||||
templates,
|
||||
}: SharedTemplatesProps) {
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<SharedTemplate | null>(null);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const { execute: executeClone, isPending: isCloning } = useAction(
|
||||
cloneTemplate,
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Vorlage wurde erfolgreich geklont');
|
||||
setCloneDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
setNewName('');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Klonen der Vorlage');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (filter === 'all') return true;
|
||||
return t.template_source === filter;
|
||||
});
|
||||
|
||||
function openCloneDialog(template: SharedTemplate) {
|
||||
setSelectedTemplate(template);
|
||||
setNewName(`${template.name} (Kopie)`);
|
||||
setCloneDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleClone() {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
executeClone({
|
||||
templateType: selectedTemplate.template_source,
|
||||
templateId: selectedTemplate.id,
|
||||
targetAccountId: accountId,
|
||||
newName: newName.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const filterButtons: { value: FilterType; label: string }[] = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'newsletter', label: 'Newsletter' },
|
||||
{ value: 'document', label: 'Dokumente' },
|
||||
];
|
||||
|
||||
function getTemplateTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
generic: 'Allgemein',
|
||||
member_card: 'Mitgliedsausweis',
|
||||
invoice: 'Rechnung',
|
||||
certificate: 'Urkunde',
|
||||
confirmation: 'Bestätigung',
|
||||
letter: 'Brief',
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Geteilte Vorlagen</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
{filterButtons.map((btn) => (
|
||||
<Button
|
||||
key={btn.value}
|
||||
data-test={`templates-filter-${btn.value}`}
|
||||
variant={filter === btn.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter(btn.value)}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="text-sm font-medium">
|
||||
Keine geteilten Vorlagen vorhanden
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Vorlagen, die von anderen Organisationen in Ihrer Hierarchie
|
||||
geteilt werden, erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-left">
|
||||
<th className="pr-4 pb-2 font-medium">Name</th>
|
||||
<th className="pr-4 pb-2 font-medium">Typ</th>
|
||||
<th className="pr-4 pb-2 font-medium">Template-Typ</th>
|
||||
<th className="pr-4 pb-2 font-medium">Organisation</th>
|
||||
<th className="pr-4 pb-2 font-medium">Erstellt</th>
|
||||
<th className="pb-2 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTemplates.map((template) => (
|
||||
<tr
|
||||
key={`${template.template_source}-${template.id}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.template_source === 'newsletter' ? (
|
||||
<Mail className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium">{template.name}</span>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={
|
||||
template.template_source === 'newsletter'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{template.template_source === 'newsletter'
|
||||
? 'Newsletter'
|
||||
: 'Dokument'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground py-3 pr-4">
|
||||
{getTemplateTypeLabel(template.template_type)}
|
||||
</td>
|
||||
<td className="text-muted-foreground py-3 pr-4">
|
||||
{template.account_name}
|
||||
</td>
|
||||
<td className="text-muted-foreground py-3 pr-4 whitespace-nowrap">
|
||||
{formatDate(template.created_at)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
data-test="template-clone-btn"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openCloneDialog(template)}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Klonen
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={cloneDialogOpen} onOpenChange={setCloneDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Vorlage klonen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erstellen Sie eine Kopie der Vorlage{' '}
|
||||
<strong>{selectedTemplate?.name}</strong> in Ihrer Organisation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label
|
||||
htmlFor="clone-name"
|
||||
className="mb-2 block text-sm font-medium"
|
||||
>
|
||||
Name der Kopie
|
||||
</label>
|
||||
<Input
|
||||
id="clone-name"
|
||||
data-test="template-clone-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name der neuen Vorlage"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCloneDialogOpen(false)}
|
||||
disabled={isCloning}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
data-test="template-clone-confirm"
|
||||
onClick={handleClone}
|
||||
disabled={isCloning}
|
||||
>
|
||||
{isCloning ? 'Wird geklont...' : 'Vorlage klonen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface VerbandTabNavigationProps {
|
||||
@@ -10,11 +11,15 @@ interface VerbandTabNavigationProps {
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Übersicht', path: '' },
|
||||
{ id: 'clubs', label: 'Vereine', path: '/clubs' },
|
||||
{ id: 'hierarchy', label: 'Hierarchie', path: '/hierarchy' },
|
||||
{ id: 'statistics', label: 'Statistik', path: '/statistics' },
|
||||
{ id: 'settings', label: 'Einstellungen', path: '/settings' },
|
||||
{ id: 'overview', i18nKey: 'verband:nav.overview', path: '' },
|
||||
{ id: 'clubs', i18nKey: 'verband:nav.clubs', path: '/clubs' },
|
||||
{ id: 'hierarchy', i18nKey: 'verband:nav.hierarchy', path: '/hierarchy' },
|
||||
{ id: 'members', i18nKey: 'verband:nav.memberSearch', path: '/members' },
|
||||
{ id: 'events', i18nKey: 'verband:nav.events', path: '/events' },
|
||||
{ id: 'reporting', i18nKey: 'verband:nav.reporting', path: '/reporting' },
|
||||
{ id: 'templates', i18nKey: 'verband:nav.templates', path: '/templates' },
|
||||
{ id: 'statistics', i18nKey: 'verband:nav.statistics', path: '/statistics' },
|
||||
{ id: 'settings', i18nKey: 'verband:nav.settings', path: '/settings' },
|
||||
] as const;
|
||||
|
||||
export function VerbandTabNavigation({
|
||||
@@ -27,7 +32,7 @@ export function VerbandTabNavigation({
|
||||
<div className="mb-6 border-b">
|
||||
<nav
|
||||
className="-mb-px flex space-x-1 overflow-x-auto"
|
||||
aria-label="Verbandsverwaltung Navigation"
|
||||
aria-label="Association Management Navigation"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
@@ -43,7 +48,7 @@ export function VerbandTabNavigation({
|
||||
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
<Trans i18nKey={tab.i18nKey} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -218,3 +218,35 @@ export const RemoveAccountParentSchema = z.object({
|
||||
export type RemoveAccountParentInput = z.infer<
|
||||
typeof RemoveAccountParentSchema
|
||||
>;
|
||||
|
||||
// =====================================================
|
||||
// Member Transfer
|
||||
// =====================================================
|
||||
|
||||
export const TransferPreviewSchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type TransferPreviewInput = z.infer<typeof TransferPreviewSchema>;
|
||||
|
||||
export const TransferMemberSchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
targetAccountId: z.string().uuid(),
|
||||
reason: z.string().max(500).optional(),
|
||||
keepSepa: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type TransferMemberInput = z.infer<typeof TransferMemberSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Clone Template
|
||||
// =====================================================
|
||||
|
||||
export const CloneTemplateSchema = z.object({
|
||||
templateType: z.enum(['newsletter', 'document']),
|
||||
templateId: z.string().uuid(),
|
||||
targetAccountId: z.string().uuid(),
|
||||
newName: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export type CloneTemplateInput = z.infer<typeof CloneTemplateSchema>;
|
||||
|
||||
@@ -9,7 +9,11 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import {
|
||||
SetAccountParentSchema,
|
||||
RemoveAccountParentSchema,
|
||||
TransferPreviewSchema,
|
||||
TransferMemberSchema,
|
||||
CloneTemplateSchema,
|
||||
} from '../../schema/verband.schema';
|
||||
import { createVerbandApi } from '../api';
|
||||
|
||||
const REVALIDATE_PATH = '/home/[account]/verband';
|
||||
|
||||
@@ -124,3 +128,79 @@ export const unlinkChildAccount = authActionClient
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const getTransferPreview = authActionClient
|
||||
.inputSchema(TransferPreviewSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
return api.getTransferPreview(input.memberId);
|
||||
});
|
||||
|
||||
export const transferMember = authActionClient
|
||||
.inputSchema(TransferMemberSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'verband.member.transfer',
|
||||
memberId: input.memberId,
|
||||
targetAccountId: input.targetAccountId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
'Transferring member to another organisation...',
|
||||
);
|
||||
|
||||
try {
|
||||
const { transferId } = await api.transferMember(
|
||||
input.memberId,
|
||||
input.targetAccountId,
|
||||
input.reason,
|
||||
input.keepSepa,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ name: 'verband.member.transfer', transferId },
|
||||
'Member transferred successfully',
|
||||
);
|
||||
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, transferId };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Fehler beim Transfer';
|
||||
logger.error({ error: err }, 'Member transfer failed');
|
||||
throw new Error(message);
|
||||
}
|
||||
});
|
||||
|
||||
export const cloneTemplate = authActionClient
|
||||
.inputSchema(CloneTemplateSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'verband.template.clone',
|
||||
templateId: input.templateId,
|
||||
targetAccountId: input.targetAccountId,
|
||||
},
|
||||
'Cloning shared template...',
|
||||
);
|
||||
|
||||
const { newTemplateId } = await api.cloneTemplate(
|
||||
input.templateType,
|
||||
input.templateId,
|
||||
input.targetAccountId,
|
||||
input.newName,
|
||||
);
|
||||
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, newTemplateId };
|
||||
});
|
||||
|
||||
@@ -787,16 +787,16 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
async getParentAccount(
|
||||
accountId: string,
|
||||
): Promise<HierarchyAccount | null> {
|
||||
const { data: acct, error: acctError } = await client
|
||||
const { data: accountData, error: accountError } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', accountId)
|
||||
.single();
|
||||
if (acctError) throw acctError;
|
||||
if (accountError) throw accountError;
|
||||
|
||||
const account = acct as unknown as HierarchyAccount;
|
||||
const account = accountData as unknown as HierarchyAccount;
|
||||
if (!account.parent_account_id) return null;
|
||||
|
||||
const { data, error } = await client
|
||||
@@ -880,14 +880,14 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const { data: fallback, error: fbErr } = await client
|
||||
const { data: fallback, error: fallbackError } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', rootAccountId)
|
||||
.single();
|
||||
if (fbErr) throw fbErr;
|
||||
if (fallbackError) throw fallbackError;
|
||||
return {
|
||||
...(fallback as unknown as HierarchyAccount),
|
||||
children: [],
|
||||
@@ -935,5 +935,478 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as HierarchyAccount[];
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Cross-Org Member Search
|
||||
// =====================================================
|
||||
|
||||
async searchMembersAcrossHierarchy(
|
||||
rootAccountId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
accountId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc(
|
||||
'search_members_across_hierarchy' as any,
|
||||
{
|
||||
root_account_id: rootAccountId,
|
||||
search_term: opts?.search || null,
|
||||
status_filter: opts?.status || null,
|
||||
account_filter: opts?.accountId || null,
|
||||
page_number: opts?.page ?? 1,
|
||||
page_size: opts?.pageSize ?? 25,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const rows = (data ?? []) as unknown as CrossOrgMemberRow[];
|
||||
const total = rows.length > 0 ? Number(rows[0]!.total_count) : 0;
|
||||
|
||||
return {
|
||||
data: rows.map(({ total_count: _, ...member }) => member),
|
||||
total,
|
||||
};
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Member Transfer
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Pre-flight check: fetch all active relationships for a member
|
||||
* so the admin can see side effects before confirming transfer.
|
||||
*/
|
||||
async getTransferPreview(memberId: string): Promise<TransferPreview> {
|
||||
// Fetch member basic info
|
||||
const { data: member } = await client
|
||||
.from('members')
|
||||
.select(
|
||||
'id, first_name, last_name, account_id, email, iban, sepa_mandate_status, dues_category_id, member_number',
|
||||
)
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
|
||||
if (!member) throw new Error('Mitglied nicht gefunden');
|
||||
|
||||
// Active course enrollments
|
||||
const { data: courses } = await client
|
||||
.from('course_participants')
|
||||
.select(
|
||||
'id, course_id, status, courses(id, name, account_id, start_date, end_date, accounts(name))',
|
||||
)
|
||||
.eq('member_id', memberId)
|
||||
.eq('status', 'enrolled');
|
||||
|
||||
// Open invoices referencing this member
|
||||
const { data: invoices } = await client
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, total_amount, status, account_id, accounts(name)',
|
||||
)
|
||||
.eq('member_id', memberId)
|
||||
.in('status', ['draft', 'sent', 'overdue']);
|
||||
|
||||
// Active SEPA mandates
|
||||
const { data: mandates } = await client
|
||||
.from('sepa_mandates')
|
||||
.select('id, mandate_reference, status, account_id')
|
||||
.eq('member_id', memberId)
|
||||
.in('status', ['active', 'pending']);
|
||||
|
||||
// Newsletter subscriptions
|
||||
const { data: newsletters } = await client
|
||||
.from('newsletter_recipients')
|
||||
.select(
|
||||
'id, newsletter_id, newsletters(id, name, account_id, accounts(name))',
|
||||
)
|
||||
.eq('member_id', memberId);
|
||||
|
||||
const courseList = (courses ?? []).map((courseParticipant) => {
|
||||
const course = courseParticipant.courses as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const courseAccount = course?.accounts as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
return {
|
||||
id: String(courseParticipant.id),
|
||||
name: String(course?.name ?? '—'),
|
||||
accountName: String(courseAccount?.name ?? '—'),
|
||||
startDate: course?.start_date as string | null,
|
||||
endDate: course?.end_date as string | null,
|
||||
survives: true, // FK is member_id, enrollment stays
|
||||
};
|
||||
});
|
||||
|
||||
const invoiceList = (invoices ?? []).map((invoiceRecord) => {
|
||||
const invAccount = invoiceRecord.accounts as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
return {
|
||||
id: String(invoiceRecord.id),
|
||||
invoiceNumber: String(invoiceRecord.invoice_number ?? '—'),
|
||||
amount: Number(invoiceRecord.total_amount ?? 0),
|
||||
status: String(invoiceRecord.status),
|
||||
accountName: String(invAccount?.name ?? '—'),
|
||||
survives: true, // stays in source org, member_id FK preserved
|
||||
};
|
||||
});
|
||||
|
||||
const mandateList = (mandates ?? []).map((mandateRecord) => ({
|
||||
id: String(mandateRecord.id),
|
||||
reference: String(mandateRecord.mandate_reference ?? '—'),
|
||||
status: String(mandateRecord.status),
|
||||
survives: false, // mandate is org-specific, gets reset
|
||||
}));
|
||||
|
||||
const newsletterList = (newsletters ?? []).map((recipientRecord) => {
|
||||
const newsletterData = recipientRecord.newsletters as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const newsletterAccountData = newsletterData?.accounts as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
return {
|
||||
id: String(recipientRecord.id),
|
||||
name: String(newsletterData?.name ?? '—'),
|
||||
accountName: String(newsletterAccountData?.name ?? '—'),
|
||||
survives: true, // FK on member_id, stays linked
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
member: {
|
||||
id: String(member.id),
|
||||
firstName: String(member.first_name),
|
||||
lastName: String(member.last_name),
|
||||
email: member.email as string | null,
|
||||
hasIban: !!member.iban,
|
||||
hasMandate:
|
||||
member.sepa_mandate_status !== 'pending' &&
|
||||
!!member.sepa_mandate_status,
|
||||
hasDuesCategory: !!member.dues_category_id,
|
||||
memberNumber: member.member_number as string | null,
|
||||
},
|
||||
courses: courseList,
|
||||
invoices: invoiceList,
|
||||
mandates: mandateList,
|
||||
newsletters: newsletterList,
|
||||
};
|
||||
},
|
||||
|
||||
async transferMember(
|
||||
memberId: string,
|
||||
targetAccountId: string,
|
||||
reason?: string,
|
||||
keepSepa = true,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc('transfer_member' as any, {
|
||||
p_member_id: memberId,
|
||||
p_target_account_id: targetAccountId,
|
||||
p_reason: reason || null,
|
||||
p_keep_sepa: keepSepa,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { transferId: data as unknown as string };
|
||||
},
|
||||
|
||||
async getMemberTransferHistory(memberId: string) {
|
||||
const { data, error } = await client
|
||||
.from('member_transfers' as string)
|
||||
.select(
|
||||
'id, member_id, source_account_id, target_account_id, reason, transferred_at' as '*',
|
||||
)
|
||||
.eq('member_id', memberId)
|
||||
.order('transferred_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as MemberTransfer[];
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Shared Events (Hierarchy)
|
||||
// =====================================================
|
||||
|
||||
async listHierarchyEvents(
|
||||
rootAccountId: string,
|
||||
opts?: {
|
||||
fromDate?: string;
|
||||
status?: string;
|
||||
sharedOnly?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc('list_hierarchy_events' as any, {
|
||||
root_account_id: rootAccountId,
|
||||
p_from_date: opts?.fromDate || null,
|
||||
p_status: opts?.status || null,
|
||||
p_shared_only: opts?.sharedOnly ?? false,
|
||||
p_page: opts?.page ?? 1,
|
||||
p_page_size: opts?.pageSize ?? 25,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
const rows = (data ?? []) as unknown as HierarchyEventRow[];
|
||||
const total = rows.length > 0 ? Number(rows[0]!.total_count) : 0;
|
||||
return {
|
||||
data: rows.map(({ total_count: _, ...event }) => event),
|
||||
total,
|
||||
};
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Consolidated SEPA Billing
|
||||
// =====================================================
|
||||
|
||||
async listSepaEligibleMembers(
|
||||
rootAccountId: string,
|
||||
accountFilter?: string,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc(
|
||||
'list_hierarchy_sepa_eligible_members' as any,
|
||||
{
|
||||
root_account_id: rootAccountId,
|
||||
p_account_filter: accountFilter || null,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as SepaEligibleMember[];
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Aggregated Reporting
|
||||
// =====================================================
|
||||
|
||||
async getHierarchyReport(rootAccountId: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc('get_hierarchy_report' as any, {
|
||||
root_account_id: rootAccountId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as HierarchyReportRow[];
|
||||
},
|
||||
|
||||
async getHierarchySummary(rootAccountId: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc('get_hierarchy_summary' as any, {
|
||||
root_account_id: rootAccountId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
const rows = (data ?? []) as unknown as HierarchySummary[];
|
||||
return (
|
||||
rows[0] ?? {
|
||||
total_orgs: 0,
|
||||
total_active_members: 0,
|
||||
total_members: 0,
|
||||
new_members_this_year: 0,
|
||||
total_upcoming_events: 0,
|
||||
total_active_courses: 0,
|
||||
total_open_invoices: 0,
|
||||
total_open_invoice_amount: 0,
|
||||
total_sepa_batches_this_year: 0,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Shared Templates
|
||||
// =====================================================
|
||||
|
||||
async listSharedTemplates(
|
||||
rootAccountId: string,
|
||||
templateType?: 'newsletter' | 'document',
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc(
|
||||
'list_hierarchy_shared_templates' as any,
|
||||
{
|
||||
root_account_id: rootAccountId,
|
||||
p_template_type: templateType || null,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as SharedTemplate[];
|
||||
},
|
||||
|
||||
async cloneTemplate(
|
||||
templateType: 'newsletter' | 'document',
|
||||
templateId: string,
|
||||
targetAccountId: string,
|
||||
newName?: string,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await client.rpc('clone_template' as any, {
|
||||
p_template_type: templateType,
|
||||
p_template_id: templateId,
|
||||
p_target_account_id: targetAccountId,
|
||||
p_new_name: newName || null,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { newTemplateId: data as unknown as string };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemberTransfer {
|
||||
id: string;
|
||||
member_id: string;
|
||||
source_account_id: string;
|
||||
target_account_id: string;
|
||||
reason: string | null;
|
||||
transferred_at: string;
|
||||
}
|
||||
|
||||
export interface CrossOrgMemberRow {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
account_slug: string | null;
|
||||
member_number: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
city: string | null;
|
||||
status: string;
|
||||
entry_date: string | null;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export type CrossOrgMember = Omit<CrossOrgMemberRow, 'total_count'>;
|
||||
|
||||
export interface TransferPreview {
|
||||
member: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
hasIban: boolean;
|
||||
hasMandate: boolean;
|
||||
hasDuesCategory: boolean;
|
||||
memberNumber: string | null;
|
||||
};
|
||||
courses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
accountName: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
survives: boolean;
|
||||
}>;
|
||||
invoices: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
accountName: string;
|
||||
survives: boolean;
|
||||
}>;
|
||||
mandates: Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
survives: boolean;
|
||||
}>;
|
||||
newsletters: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
accountName: string;
|
||||
survives: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface HierarchyEventRow {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
event_date: string;
|
||||
event_time: string | null;
|
||||
end_date: string | null;
|
||||
location: string | null;
|
||||
capacity: number | null;
|
||||
fee: number;
|
||||
status: string;
|
||||
registration_deadline: string | null;
|
||||
registration_count: number;
|
||||
shared_with_hierarchy: boolean;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export type HierarchyEvent = Omit<HierarchyEventRow, 'total_count'>;
|
||||
|
||||
export interface SepaEligibleMember {
|
||||
member_id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
iban: string;
|
||||
bic: string | null;
|
||||
account_holder: string | null;
|
||||
mandate_id: string | null;
|
||||
mandate_date: string | null;
|
||||
dues_amount: number;
|
||||
}
|
||||
|
||||
export interface HierarchyReportRow {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
org_slug: string | null;
|
||||
depth: number;
|
||||
active_members: number;
|
||||
inactive_members: number;
|
||||
total_members: number;
|
||||
new_members_this_year: number;
|
||||
active_courses: number;
|
||||
upcoming_events: number;
|
||||
open_invoices: number;
|
||||
open_invoice_amount: number;
|
||||
sepa_batches_this_year: number;
|
||||
}
|
||||
|
||||
export interface HierarchySummary {
|
||||
total_orgs: number;
|
||||
total_active_members: number;
|
||||
total_members: number;
|
||||
new_members_this_year: number;
|
||||
total_upcoming_events: number;
|
||||
total_active_courses: number;
|
||||
total_open_invoices: number;
|
||||
total_open_invoice_amount: number;
|
||||
total_sepa_batches_this_year: number;
|
||||
}
|
||||
|
||||
export interface SharedTemplate {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
template_source: 'newsletter' | 'document';
|
||||
name: string;
|
||||
description: string | null;
|
||||
template_type: string;
|
||||
shared_with_hierarchy: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ export function getSupabaseClientKeys() {
|
||||
const isServer = typeof window === 'undefined';
|
||||
|
||||
const url = isServer
|
||||
? (process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL)
|
||||
? process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
: process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
|
||||
return z
|
||||
|
||||
Reference in New Issue
Block a user