feat: enhance member management features; add quick stats and search capabilities
This commit is contained in:
@@ -4,7 +4,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Member Management', () => {
|
test.describe('Member Management', () => {
|
||||||
test('create member, edit, search, filter by status', async ({ page: _page }) => {
|
test('create member, edit, search, filter by status', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
await page.fill('input[name="email"]', 'test@example.com');
|
await page.fill('input[name="email"]', 'test@example.com');
|
||||||
await page.fill('input[name="password"]', 'testpassword123');
|
await page.fill('input[name="password"]', 'testpassword123');
|
||||||
|
|||||||
@@ -457,10 +457,7 @@ export function FeatureCarousel() {
|
|||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const slide = SLIDES[active]!;
|
const slide = SLIDES[active]!;
|
||||||
|
|
||||||
const next = useCallback(
|
const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
|
||||||
() => setActive((i) => (i + 1) % SLIDES.length),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const prev = useCallback(
|
const prev = useCallback(
|
||||||
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
|
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react';
|
|||||||
|
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -265,7 +262,10 @@ function PriceBar({
|
|||||||
<div className="bg-muted h-6 overflow-hidden rounded-md">
|
<div className="bg-muted h-6 overflow-hidden rounded-md">
|
||||||
{available ? (
|
{available ? (
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-md transition-all duration-500', color)}
|
className={cn(
|
||||||
|
'h-full rounded-md transition-all duration-500',
|
||||||
|
color,
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
minWidth: pct > 0 ? 4 : 0,
|
minWidth: pct > 0 ? 4 : 0,
|
||||||
@@ -323,7 +323,10 @@ export function PricingCalculator() {
|
|||||||
<div className="mx-auto w-full max-w-4xl space-y-0">
|
<div className="mx-auto w-full max-w-4xl space-y-0">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="bg-primary rounded-t-2xl px-8 py-7">
|
<div className="bg-primary rounded-t-2xl px-8 py-7">
|
||||||
<Badge variant="outline" className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] uppercase tracking-widest">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] tracking-widest uppercase"
|
||||||
|
>
|
||||||
Preisvergleich
|
Preisvergleich
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
|
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
|
||||||
@@ -376,7 +379,7 @@ export function PricingCalculator() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
|
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||||
Ihr MYeasyCMS-Tarif
|
Ihr MYeasyCMS-Tarif
|
||||||
</div>
|
</div>
|
||||||
<div className="font-heading text-primary text-xl font-bold">
|
<div className="font-heading text-primary text-xl font-bold">
|
||||||
@@ -460,21 +463,20 @@ export function PricingCalculator() {
|
|||||||
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
|
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
|
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
|
||||||
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||||
Ersparnis vs. {bestSaving.name.split(' ')[0]}
|
Ersparnis vs. {bestSaving.name.split(' ')[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
{fmt((bestSaving.p - tier.price) * 12)} €
|
{fmt((bestSaving.p - tier.price) * 12)} €
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
pro Jahr (
|
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
||||||
{Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
|
||||||
günstiger)
|
günstiger)
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-muted/50 p-5 text-center">
|
<Card className="bg-muted/50 p-5 text-center">
|
||||||
<div className="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">
|
<div className="text-muted-foreground text-[10px] font-bold tracking-wider uppercase">
|
||||||
Preis pro Mitglied
|
Preis pro Mitglied
|
||||||
</div>
|
</div>
|
||||||
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
@@ -521,19 +523,19 @@ export function PricingCalculator() {
|
|||||||
{USP_FEATURES.map((f, i) => (
|
{USP_FEATURES.map((f, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
<TableCell className="font-medium">{f.label}</TableCell>
|
<TableCell className="font-medium">{f.label}</TableCell>
|
||||||
{(
|
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
|
||||||
['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
|
(col) => (
|
||||||
).map((col) => (
|
<TableCell
|
||||||
<TableCell
|
key={col}
|
||||||
key={col}
|
className={cn(
|
||||||
className={cn(
|
'text-center',
|
||||||
'text-center',
|
col === 'mcms' && 'bg-primary/5',
|
||||||
col === 'mcms' && 'bg-primary/5',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<FeatureCell value={f[col]} />
|
||||||
<FeatureCell value={f[col]} />
|
</TableCell>
|
||||||
</TableCell>
|
),
|
||||||
))}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function EditMemberPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const member = await api.getMember(memberId);
|
const member = await api.getMember(acct.id, memberId);
|
||||||
if (!member) return <div>{t('detail.notFound')}</div>;
|
if (!member) return <div>{t('detail.notFound')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { MemberDetailView } from '@kit/member-management/components';
|
import { MemberDetailTabs } from '@kit/member-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string; memberId: string }>;
|
params: Promise<{ account: string; memberId: string }>;
|
||||||
@@ -20,10 +19,9 @@ export default async function MemberDetailPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const member = await api.getMember(memberId);
|
const member = await api.getMember(acct.id, memberId);
|
||||||
if (!member) return <AccountNotFound />;
|
if (!member) return <AccountNotFound />;
|
||||||
|
|
||||||
// Fetch sub-entities in parallel
|
|
||||||
const [roles, honors, mandates] = await Promise.all([
|
const [roles, honors, mandates] = await Promise.all([
|
||||||
api.listMemberRoles(memberId),
|
api.listMemberRoles(memberId),
|
||||||
api.listMemberHonors(memberId),
|
api.listMemberHonors(memberId),
|
||||||
@@ -31,18 +29,13 @@ export default async function MemberDetailPage({ params }: Props) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<MemberDetailTabs
|
||||||
|
member={member}
|
||||||
account={account}
|
account={account}
|
||||||
title={`${String(member.first_name)} ${String(member.last_name)}`}
|
accountId={acct.id}
|
||||||
>
|
roles={roles}
|
||||||
<MemberDetailView
|
honors={honors}
|
||||||
member={member}
|
mandates={mandates}
|
||||||
account={account}
|
/>
|
||||||
accountId={acct.id}
|
|
||||||
roles={roles}
|
|
||||||
honors={honors}
|
|
||||||
mandates={mandates}
|
|
||||||
/>
|
|
||||||
</CmsPageShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileDown,
|
||||||
|
FileUp,
|
||||||
|
IdCard,
|
||||||
|
KeyRound,
|
||||||
|
LayoutList,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MemberStatsBar,
|
||||||
|
MemberCommandPalette,
|
||||||
|
} from '@kit/member-management/components';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
interface MembersCmsLayoutClientProps {
|
||||||
|
header: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
pending: number;
|
||||||
|
newThisYear: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersCmsLayoutClient({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
stats,
|
||||||
|
}: MembersCmsLayoutClientProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const basePath = `/home/${account}/members-cms`;
|
||||||
|
|
||||||
|
const isOnMembersTab =
|
||||||
|
pathname.endsWith('/members-cms') ||
|
||||||
|
pathname.includes('/members-cms/new') ||
|
||||||
|
/\/members-cms\/[^/]+$/.test(pathname);
|
||||||
|
const isOnApplicationsTab = pathname.includes('/applications');
|
||||||
|
const isOnSubPage =
|
||||||
|
pathname.includes('/import') ||
|
||||||
|
pathname.includes('/edit') ||
|
||||||
|
(/\/members-cms\/[^/]+$/.test(pathname) &&
|
||||||
|
!pathname.endsWith('/members-cms'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
<PageBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats bar — only on main views */}
|
||||||
|
{!isOnSubPage && <MemberStatsBar stats={stats} />}
|
||||||
|
|
||||||
|
{/* Tab navigation + settings */}
|
||||||
|
{!isOnSubPage && (
|
||||||
|
<div className="flex items-center justify-between border-b">
|
||||||
|
<nav className="-mb-px flex gap-4">
|
||||||
|
<TabLink
|
||||||
|
href={basePath}
|
||||||
|
active={isOnMembersTab && !isOnApplicationsTab}
|
||||||
|
>
|
||||||
|
<Users className="size-4" />
|
||||||
|
Mitglieder
|
||||||
|
</TabLink>
|
||||||
|
<TabLink
|
||||||
|
href={`${basePath}/applications`}
|
||||||
|
active={isOnApplicationsTab}
|
||||||
|
>
|
||||||
|
<FileUp className="size-4" />
|
||||||
|
Aufnahmeanträge
|
||||||
|
{stats.pendingApplications > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-1 h-5 min-w-5 px-1 text-xs"
|
||||||
|
>
|
||||||
|
{stats.pendingApplications}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<SettingsMenu basePath={basePath} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MemberCommandPalette account={account} accountId={accountId} />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabLink({
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
active: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 border-b-2 px-1 pb-2 text-sm font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsMenu({ basePath }: { basePath: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const navigate = (path: string) => () => router.push(path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded-lg"
|
||||||
|
data-test="members-settings-menu"
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/dues`)}>
|
||||||
|
<LayoutList className="mr-2 size-4" />
|
||||||
|
Beitragskategorien
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/departments`)}>
|
||||||
|
<Users className="mr-2 size-4" />
|
||||||
|
Abteilungen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
|
||||||
|
<IdCard className="mr-2 size-4" />
|
||||||
|
Mitgliedsausweise
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/invitations`)}>
|
||||||
|
<KeyRound className="mr-2 size-4" />
|
||||||
|
Portal-Einladungen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/import`)}>
|
||||||
|
<FileDown className="mr-2 size-4" />
|
||||||
|
Import
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -14,7 +11,7 @@ interface Props {
|
|||||||
export default async function ApplicationsPage({ params }: Props) {
|
export default async function ApplicationsPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const t = await getTranslations('members');
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -26,16 +23,10 @@ export default async function ApplicationsPage({ params }: Props) {
|
|||||||
const applications = await api.listApplications(acct.id);
|
const applications = await api.listApplications(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<ApplicationWorkflow
|
||||||
|
applications={applications}
|
||||||
|
accountId={acct.id}
|
||||||
account={account}
|
account={account}
|
||||||
title={t('nav.applications')}
|
/>
|
||||||
description={t('applications.subtitle')}
|
|
||||||
>
|
|
||||||
<ApplicationWorkflow
|
|
||||||
applications={applications}
|
|
||||||
accountId={acct.id}
|
|
||||||
account={account}
|
|
||||||
/>
|
|
||||||
</CmsPageShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
|
||||||
|
|
||||||
|
import { MembersCmsLayoutClient } from './_components/members-cms-layout-client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MembersCmsLayout({ children, params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const stats = await api.getMemberQuickStats(acct.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MembersCmsLayoutClient
|
||||||
|
header={
|
||||||
|
<TeamAccountLayoutPageHeader
|
||||||
|
account={account}
|
||||||
|
title="Mitglieder"
|
||||||
|
description={`${stats.total} Mitglieder verwalten`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
account={account}
|
||||||
|
accountId={acct.id}
|
||||||
|
stats={stats}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MembersCmsLayoutClient>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { CreateMemberForm } from '@kit/member-management/components';
|
import { MemberCreateWizard } from '@kit/member-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -13,7 +10,6 @@ interface Props {
|
|||||||
|
|
||||||
export default async function NewMemberPage({ params }: Props) {
|
export default async function NewMemberPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const t = await getTranslations('members');
|
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -26,22 +22,16 @@ export default async function NewMemberPage({ params }: Props) {
|
|||||||
const duesCategories = await api.listDuesCategories(acct.id);
|
const duesCategories = await api.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<MemberCreateWizard
|
||||||
|
accountId={acct.id}
|
||||||
account={account}
|
account={account}
|
||||||
title={t('form.newMemberTitle')}
|
duesCategories={(duesCategories ?? []).map(
|
||||||
description={t('form.newMemberDescription')}
|
(c: Record<string, unknown>) => ({
|
||||||
>
|
id: String(c.id),
|
||||||
<CreateMemberForm
|
name: String(c.name),
|
||||||
accountId={acct.id}
|
amount: Number(c.amount ?? 0),
|
||||||
account={account}
|
}),
|
||||||
duesCategories={(duesCategories ?? []).map(
|
)}
|
||||||
(c: Record<string, unknown>) => ({
|
/>
|
||||||
id: String(c.id),
|
|
||||||
name: String(c.name),
|
|
||||||
amount: Number(c.amount ?? 0),
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CmsPageShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { MembersDataTable } from '@kit/member-management/components';
|
import { MembersListView } from '@kit/member-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const t = await getTranslations('members');
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -28,34 +25,50 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
const result = await api.listMembers(acct.id, {
|
|
||||||
|
// Parse multi-status filter
|
||||||
|
const statusParam = search.status;
|
||||||
|
const statusFilter = statusParam
|
||||||
|
? Array.isArray(statusParam)
|
||||||
|
? statusParam
|
||||||
|
: statusParam.split(',')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await api.searchMembers({
|
||||||
|
accountId: acct.id,
|
||||||
search: search.q as string,
|
search: search.q as string,
|
||||||
status: search.status as string,
|
status: statusFilter as any,
|
||||||
|
duesCategoryId: search.duesCategoryId as string,
|
||||||
|
sortBy: (search.sortBy as string) ?? 'last_name',
|
||||||
|
sortDirection: (search.sortDirection as 'asc' | 'desc') ?? 'asc',
|
||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const duesCategories = await api.listDuesCategories(acct.id);
|
|
||||||
|
const [duesCategories, departments] = await Promise.all([
|
||||||
|
api.listDuesCategories(acct.id),
|
||||||
|
api.listDepartmentsWithCounts(acct.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<MembersListView
|
||||||
|
data={result.data}
|
||||||
|
total={result.total}
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
account={account}
|
account={account}
|
||||||
title={t('nav.members')}
|
accountId={acct.id}
|
||||||
description={`${result.total} ${t('nav.members')}`}
|
duesCategories={(duesCategories ?? []).map(
|
||||||
>
|
(c: Record<string, unknown>) => ({
|
||||||
<MembersDataTable
|
id: String(c.id),
|
||||||
data={result.data}
|
name: String(c.name),
|
||||||
total={result.total}
|
}),
|
||||||
page={page}
|
)}
|
||||||
pageSize={PAGE_SIZE}
|
departments={(departments ?? []).map((d) => ({
|
||||||
account={account}
|
id: String(d.id),
|
||||||
accountId={acct.id}
|
name: String(d.name),
|
||||||
duesCategories={(duesCategories ?? []).map(
|
memberCount: d.memberCount,
|
||||||
(c: Record<string, unknown>) => ({
|
}))}
|
||||||
id: String(c.id),
|
/>
|
||||||
name: String(c.name),
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CmsPageShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { FileText, Plus } from 'lucide-react';
|
import { FileText, Plus } from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
// People (Members + Access)
|
// People (Members + Access)
|
||||||
UserCheck,
|
UserCheck,
|
||||||
UserPlus,
|
|
||||||
IdCard,
|
|
||||||
ClipboardList,
|
|
||||||
// Courses
|
// Courses
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
@@ -69,7 +66,10 @@ import pathsConfig from '~/config/paths.config';
|
|||||||
|
|
||||||
const iconClasses = 'w-4';
|
const iconClasses = 'w-4';
|
||||||
|
|
||||||
const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) => {
|
const getRoutes = (
|
||||||
|
account: string,
|
||||||
|
accountFeatures?: Record<string, boolean>,
|
||||||
|
) => {
|
||||||
const routes: Array<
|
const routes: Array<
|
||||||
| {
|
| {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (featureFlagsConfig.enableMemberManagement) {
|
if (featureFlagsConfig.enableMemberManagement) {
|
||||||
peopleChildren.push(
|
peopleChildren.push({
|
||||||
{
|
label: 'common.routes.clubMembers',
|
||||||
label: 'common.routes.clubMembers',
|
path: createPath(pathsConfig.app.accountCmsMembers, account),
|
||||||
path: createPath(pathsConfig.app.accountCmsMembers, account),
|
Icon: <UserCheck className={iconClasses} />,
|
||||||
Icon: <UserCheck className={iconClasses} />,
|
});
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'common.routes.memberApplications',
|
|
||||||
path: createPath(
|
|
||||||
pathsConfig.app.accountCmsMembers + '/applications',
|
|
||||||
account,
|
|
||||||
),
|
|
||||||
Icon: <UserPlus className={iconClasses} />,
|
|
||||||
},
|
|
||||||
// NOTE: memberPortal page does not exist yet — nav entry commented out until built
|
|
||||||
// {
|
|
||||||
// label: 'common.routes.memberPortal',
|
|
||||||
// path: createPath(
|
|
||||||
// pathsConfig.app.accountCmsMembers + '/portal',
|
|
||||||
// account,
|
|
||||||
// ),
|
|
||||||
// Icon: <KeyRound className={iconClasses} />,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: 'common.routes.memberCards',
|
|
||||||
path: createPath(
|
|
||||||
pathsConfig.app.accountCmsMembers + '/cards',
|
|
||||||
account,
|
|
||||||
),
|
|
||||||
Icon: <IdCard className={iconClasses} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'common.routes.memberDues',
|
|
||||||
path: createPath(
|
|
||||||
pathsConfig.app.accountCmsMembers + '/dues',
|
|
||||||
account,
|
|
||||||
),
|
|
||||||
Icon: <ClipboardList className={iconClasses} />,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin users who can log in — always visible
|
// Admin users who can log in — always visible
|
||||||
@@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Fisheries ──
|
// ── Fisheries ──
|
||||||
if (featureFlagsConfig.enableFischerei && (accountFeatures?.fischerei !== false)) {
|
if (
|
||||||
|
featureFlagsConfig.enableFischerei &&
|
||||||
|
accountFeatures?.fischerei !== false
|
||||||
|
) {
|
||||||
routes.push({
|
routes.push({
|
||||||
label: 'common.routes.fisheriesManagement',
|
label: 'common.routes.fisheriesManagement',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
@@ -473,7 +441,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Meeting Protocols ──
|
// ── Meeting Protocols ──
|
||||||
if (featureFlagsConfig.enableMeetingProtocols && (accountFeatures?.meetings !== false)) {
|
if (
|
||||||
|
featureFlagsConfig.enableMeetingProtocols &&
|
||||||
|
accountFeatures?.meetings !== false
|
||||||
|
) {
|
||||||
routes.push({
|
routes.push({
|
||||||
label: 'common.routes.meetingProtocols',
|
label: 'common.routes.meetingProtocols',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
@@ -502,7 +473,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Association Management (Verband) ──
|
// ── Association Management (Verband) ──
|
||||||
if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) {
|
if (
|
||||||
|
featureFlagsConfig.enableVerbandsverwaltung &&
|
||||||
|
accountFeatures?.verband !== false
|
||||||
|
) {
|
||||||
routes.push({
|
routes.push({
|
||||||
label: 'common.routes.associationManagement',
|
label: 'common.routes.associationManagement',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
|
|||||||
@@ -6404,6 +6404,22 @@ export type Database = {
|
|||||||
total_upcoming_events: number
|
total_upcoming_events: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
get_member_quick_stats: {
|
||||||
|
Args: { p_account_id: string }
|
||||||
|
Returns: {
|
||||||
|
active: number
|
||||||
|
inactive: number
|
||||||
|
new_this_year: number
|
||||||
|
pending: number
|
||||||
|
pending_applications: number
|
||||||
|
resigned: number
|
||||||
|
total: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
get_next_member_number: {
|
||||||
|
Args: { p_account_id: string }
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
get_nonce_status: { Args: { p_id: string }; Returns: Json }
|
get_nonce_status: { Args: { p_id: string }; Returns: Json }
|
||||||
get_upper_system_role: { Args: never; Returns: string }
|
get_upper_system_role: { Args: never; Returns: string }
|
||||||
get_user_visible_accounts: { Args: never; Returns: string[] }
|
get_user_visible_accounts: { Args: never; Returns: string[] }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const INTERNAL_PACKAGES = [
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
output: 'standalone',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: INTERNAL_PACKAGES,
|
transpilePackages: INTERNAL_PACKAGES,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
-- Migration: Enhanced member search and quick stats
|
||||||
|
-- Adds: full-text search index, quick stats RPC, next member number function
|
||||||
|
|
||||||
|
-- Full-text search index (German) for faster member search
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_fulltext ON public.members
|
||||||
|
USING gin(
|
||||||
|
to_tsvector(
|
||||||
|
'german',
|
||||||
|
coalesce(first_name, '') || ' ' ||
|
||||||
|
coalesce(last_name, '') || ' ' ||
|
||||||
|
coalesce(email, '') || ' ' ||
|
||||||
|
coalesce(member_number, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigram index on names for fuzzy / ILIKE search
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_name_trgm
|
||||||
|
ON public.members
|
||||||
|
USING gin ((lower(first_name || ' ' || last_name)) gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Quick stats RPC — returns a single row with KPI counts
|
||||||
|
-- Includes has_role_on_account guard to prevent cross-tenant data leaks
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_member_quick_stats(p_account_id uuid)
|
||||||
|
RETURNS TABLE(
|
||||||
|
total bigint,
|
||||||
|
active bigint,
|
||||||
|
inactive bigint,
|
||||||
|
pending bigint,
|
||||||
|
resigned bigint,
|
||||||
|
new_this_year bigint,
|
||||||
|
pending_applications bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Verify caller has access to this account
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied to account %', p_account_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
count(*)::bigint AS total,
|
||||||
|
count(*) FILTER (WHERE m.status = 'active')::bigint AS active,
|
||||||
|
count(*) FILTER (WHERE m.status = 'inactive')::bigint AS inactive,
|
||||||
|
count(*) FILTER (WHERE m.status = 'pending')::bigint AS pending,
|
||||||
|
count(*) FILTER (WHERE m.status = 'resigned')::bigint AS resigned,
|
||||||
|
count(*) FILTER (WHERE m.status = 'active'
|
||||||
|
AND m.entry_date >= date_trunc('year', current_date)::date)::bigint AS new_this_year,
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM public.membership_applications a
|
||||||
|
WHERE a.account_id = p_account_id
|
||||||
|
AND a.status = 'submitted'
|
||||||
|
)::bigint AS pending_applications
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = p_account_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_member_quick_stats(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- Next member number: returns max(member_number) + 1 as text
|
||||||
|
-- Includes has_role_on_account guard
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_next_member_number(p_account_id uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_result text;
|
||||||
|
BEGIN
|
||||||
|
-- Verify caller has access to this account
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied to account %', p_account_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT LPAD(
|
||||||
|
(COALESCE(
|
||||||
|
MAX(
|
||||||
|
CASE
|
||||||
|
WHEN member_number ~ '^\d+$' THEN member_number::integer
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) + 1)::text,
|
||||||
|
4,
|
||||||
|
'0'
|
||||||
|
) INTO v_result
|
||||||
|
FROM public.members
|
||||||
|
WHERE account_id = p_account_id;
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_next_member_number(uuid) TO authenticated;
|
||||||
@@ -285,3 +285,146 @@ SELECT pg_catalog.setval('"public"."role_permissions_id_seq"', 7, true);
|
|||||||
--
|
--
|
||||||
|
|
||||||
SELECT pg_catalog.setval('"supabase_functions"."hooks_id_seq"', 19, true);
|
SELECT pg_catalog.setval('"supabase_functions"."hooks_id_seq"', 19, true);
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════
|
||||||
|
-- Member Management Seed Data
|
||||||
|
-- 30 realistic German/Austrian club members for demo/development
|
||||||
|
-- ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid := '5deaa894-2094-4da3-b4fd-1fada0809d1c';
|
||||||
|
v_user_id uuid := '31a03e74-1639-45b6-bfa7-77447f1a4762';
|
||||||
|
v_cat_regular uuid;
|
||||||
|
v_cat_youth uuid;
|
||||||
|
v_cat_senior uuid;
|
||||||
|
v_dept_vorstand uuid;
|
||||||
|
v_dept_jugend uuid;
|
||||||
|
v_dept_sport uuid;
|
||||||
|
v_m1 uuid; v_m2 uuid; v_m3 uuid; v_m4 uuid; v_m5 uuid;
|
||||||
|
v_m6 uuid; v_m7 uuid; v_m8 uuid; v_m9 uuid; v_m10 uuid;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
-- Dues Categories
|
||||||
|
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_default, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Erwachsene', 'Regulärer Mitgliedsbeitrag', 120.00, 'yearly', true, 1)
|
||||||
|
RETURNING id INTO v_cat_regular;
|
||||||
|
|
||||||
|
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_youth, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Jugend (bis 18)', 'Ermäßigter Jugendbeitrag', 48.00, 'yearly', true, 2)
|
||||||
|
RETURNING id INTO v_cat_youth;
|
||||||
|
|
||||||
|
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Senioren (ab 65)', 'Ermäßigter Seniorenbeitrag', 72.00, 'yearly', 3)
|
||||||
|
RETURNING id INTO v_cat_senior;
|
||||||
|
|
||||||
|
-- Departments
|
||||||
|
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Vorstand', 'Vereinsvorstand und Leitung', 1)
|
||||||
|
RETURNING id INTO v_dept_vorstand;
|
||||||
|
|
||||||
|
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Jugendabteilung', 'Kinder- und Jugendarbeit', 2)
|
||||||
|
RETURNING id INTO v_dept_jugend;
|
||||||
|
|
||||||
|
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, 'Sportabteilung', 'Training und Wettkampf', 3)
|
||||||
|
RETURNING id INTO v_dept_sport;
|
||||||
|
|
||||||
|
-- Members 1-10 (with variables for relationships)
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, is_founding_member, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0001', 'Johann', 'Maier', '1968-03-15', 'male', 'Herr', 'johann.maier@example.at', '+43 512 123456', '+43 664 1234567', 'Hauptstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2005-01-15', v_cat_regular, 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', true, true, true, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m1;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, gdpr_consent, gdpr_newsletter, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0002', 'Maria', 'Huber', '1975-07-22', 'female', 'Frau', 'maria.huber@example.at', '+43 512 234567', '+43 660 2345678', 'Bahnhofstraße', '5a', '6020', 'Innsbruck', 'AT', 'active', '2008-03-01', v_cat_regular, 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', true, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m2;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0003', 'Thomas', 'Berger', '1982-11-08', 'male', 'Herr', 'thomas.berger@example.at', '+43 512 345678', 'Museumstraße', '3', '6020', 'Innsbruck', 'AT', 'active', '2010-06-15', v_cat_regular, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m3;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0004', 'Anna', 'Steiner', '1990-04-12', 'female', 'Frau', 'anna.steiner@example.at', '+43 676 3456789', 'Leopoldstraße', '18', '6020', 'Innsbruck', 'AT', 'active', '2012-09-01', v_cat_regular, true, true, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m4;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, title, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_honorary, is_founding_member, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0005', 'Franz', 'Gruber', '1945-09-03', 'male', 'Herr', 'Dr.', 'franz.gruber@example.at', '+43 512 456789', 'Rennweg', '7', '6020', 'Innsbruck', 'AT', 'active', '1998-01-01', v_cat_senior, true, true, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m5;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, email, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_youth, guardian_name, guardian_phone, guardian_email, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0006', 'Lukas', 'Hofer', '2010-02-28', 'male', 'lukas.hofer@example.at', 'Schillerstraße', '22', '6020', 'Innsbruck', 'AT', 'active', '2022-03-01', v_cat_youth, true, 'Stefan Hofer', '+43 664 5678901', 'stefan.hofer@example.at', true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m6;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0007', 'Katharina', 'Wimmer', '1988-12-05', 'female', 'Frau', 'k.wimmer@example.at', '+43 512 567890', 'Maria-Theresien-Straße', '15', '6020', 'Innsbruck', 'AT', 'inactive', '2015-01-01', v_cat_regular, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m7;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, street, house_number, postal_code, city, country, status, entry_date, exit_date, exit_reason, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0008', 'Peter', 'Moser', '1970-06-18', 'male', 'Herr', 'peter.moser@example.at', 'Anichstraße', '29', '6020', 'Innsbruck', 'AT', 'resigned', '2010-05-01', '2025-12-31', 'Umzug', v_cat_regular, false, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m8;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0009', 'Sophie', 'Eder', '1995-08-30', 'female', 'Frau', 'sophie.eder@example.at', '+43 680 6789012', 'Universitätsstraße', '8', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-15', v_cat_regular, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m9;
|
||||||
|
|
||||||
|
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_retiree, gdpr_consent, gdpr_print, gdpr_birthday_info, created_by, updated_by)
|
||||||
|
VALUES (gen_random_uuid(), v_account_id, '0010', 'Helmut', 'Bauer', '1952-01-14', 'male', 'Herr', 'helmut.bauer@example.at', '+43 512 678901', 'Sillgasse', '14', '6020', 'Innsbruck', 'AT', 'active', '2001-07-01', v_cat_senior, true, true, true, true, v_user_id, v_user_id)
|
||||||
|
RETURNING id INTO v_m10;
|
||||||
|
|
||||||
|
-- Members 11-30 (bulk insert)
|
||||||
|
INSERT INTO public.members (account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) VALUES
|
||||||
|
(v_account_id, '0011', 'Christina', 'Pichler', '1993-05-17', 'female', 'Frau', 'christina.pichler@example.at', '+43 664 7890123', 'Innrain', '52', '6020', 'Innsbruck', 'AT', 'active', '2019-01-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0012', 'Michael', 'Ebner', '1985-09-23', 'male', 'Herr', 'michael.ebner@example.at', '+43 660 8901234', 'Höttinger Au', '3', '6020', 'Innsbruck', 'AT', 'active', '2017-04-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0013', 'Eva', 'Schwarz', '1978-02-09', 'female', 'Frau', 'eva.schwarz@example.at', '+43 676 9012345', 'Fallmerayerstraße', '6', '6020', 'Innsbruck', 'AT', 'active', '2014-09-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0014', 'Stefan', 'Wallner', '1991-11-30', 'male', 'Herr', 'stefan.wallner@example.at', '+43 664 0123456', 'Reichenauer Straße', '44', '6020', 'Innsbruck', 'AT', 'active', '2020-02-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0015', 'Martina', 'Lechner', '1987-04-25', 'female', 'Frau', 'martina.lechner@example.at', '+43 680 1234567', 'Olympiastraße', '10', '6020', 'Innsbruck', 'AT', 'active', '2016-06-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0016', 'Andreas', 'Koller', '1969-08-11', 'male', 'Herr', 'andreas.koller@example.at', '+43 664 2345670', 'Pradler Straße', '72', '6020', 'Innsbruck', 'AT', 'active', '2007-01-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0017', 'Laura', 'Reiter', '2008-07-19', 'female', NULL, 'laura.reiter@example.at', '+43 660 3456701', 'Gabelsbergerstraße', '4', '6020', 'Innsbruck', 'AT', 'active', '2023-01-01', v_cat_youth, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0018', 'Markus', 'Fuchs', '1980-10-02', 'male', 'Herr', 'markus.fuchs@example.at', '+43 676 4567012', 'Egger-Lienz-Straße', '28', '6020', 'Innsbruck', 'AT', 'active', '2013-03-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0019', 'Lisa', 'Müller', '1996-01-07', 'female', 'Frau', 'lisa.mueller@example.at', '+43 664 5670123', 'Amraser Straße', '16', '6020', 'Innsbruck', 'AT', 'active', '2021-09-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0020', 'Georg', 'Wagner', '1973-06-14', 'male', 'Herr', 'georg.wagner@example.at', '+43 680 6701234', 'Kaiserjägerstraße', '1', '6020', 'Innsbruck', 'AT', 'active', '2009-11-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0021', 'Claudia', 'Fischer', '1984-12-20', 'female', 'Frau', 'claudia.fischer@example.at', '+43 664 7012345', 'Technikerstraße', '9', '6020', 'Innsbruck', 'AT', 'active', '2018-05-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0022', 'Daniel', 'Wolf', '1998-03-28', 'male', 'Herr', 'daniel.wolf@example.at', '+43 660 8012346', 'Schöpfstraße', '31', '6020', 'Innsbruck', 'AT', 'active', '2022-01-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0023', 'Sandra', 'Brunner', '1976-09-06', 'female', 'Frau', NULL, '+43 512 901234', 'Defreggerstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2011-04-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0024', 'Robert', 'Lang', '1960-11-11', 'male', 'Herr', 'robert.lang@example.at', '+43 512 012345', 'Speckbacherstraße', '21', '6020', 'Innsbruck', 'AT', 'active', '2003-01-01', v_cat_senior, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0025', 'Nina', 'Winkler', '2009-05-03', 'female', NULL, 'nina.winkler@example.at', '+43 664 1230456', 'Müllerstraße', '7', '6020', 'Innsbruck', 'AT', 'active', '2023-09-01', v_cat_youth, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0026', 'Wolfgang', 'Schmid', '1955-04-22', 'male', 'Herr', 'wolfgang.schmid@example.at', '+43 512 2340567', 'Haller Straße', '55', '6020', 'Innsbruck', 'AT', 'inactive', '2000-06-01', v_cat_senior, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0027', 'Sabrina', 'Gruber', '1994-07-15', 'female', 'Frau', 'sabrina.gruber@example.at', '+43 676 3450678', 'Grabenweg', '33', '6020', 'Innsbruck', 'AT', 'active', '2020-11-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0028', 'Patrick', 'Stockinger', '1989-10-09', 'male', 'Herr', 'patrick.stockinger@example.at', '+43 660 4560789', 'Adamgasse', '19', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0029', 'Verena', 'Neuner', '1981-01-18', 'female', 'Frau', 'verena.neuner@example.at', '+43 664 5670890', 'Amthorstraße', '2', '6020', 'Innsbruck', 'AT', 'active', '2015-08-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||||
|
(v_account_id, '0030', 'Florian', 'Kofler', '2011-12-25', 'male', NULL, NULL, '+43 664 6780901', 'Hunoldstraße', '11', '6020', 'Innsbruck', 'AT', 'active', '2024-01-01', v_cat_youth, true, v_user_id, v_user_id);
|
||||||
|
|
||||||
|
-- Department Assignments
|
||||||
|
INSERT INTO public.member_department_assignments (member_id, department_id) VALUES
|
||||||
|
(v_m1, v_dept_vorstand), (v_m2, v_dept_vorstand), (v_m3, v_dept_vorstand),
|
||||||
|
(v_m4, v_dept_jugend), (v_m6, v_dept_jugend),
|
||||||
|
(v_m4, v_dept_sport), (v_m10, v_dept_sport);
|
||||||
|
|
||||||
|
-- Roles
|
||||||
|
INSERT INTO public.member_roles (account_id, member_id, role_name, from_date, until_date, is_active) VALUES
|
||||||
|
(v_account_id, v_m1, '1. Vorsitzender', '2015-01-01', NULL, true),
|
||||||
|
(v_account_id, v_m2, 'Kassierin', '2018-01-01', NULL, true),
|
||||||
|
(v_account_id, v_m3, 'Schriftführer', '2018-01-01', NULL, true),
|
||||||
|
(v_account_id, v_m4, 'Jugendleiterin', '2020-01-01', NULL, true),
|
||||||
|
(v_account_id, v_m1, '2. Vorsitzender', '2008-01-01', '2014-12-31', false),
|
||||||
|
(v_account_id, v_m5, '1. Vorsitzender', '1998-01-01', '2014-12-31', false);
|
||||||
|
|
||||||
|
-- Honors
|
||||||
|
INSERT INTO public.member_honors (account_id, member_id, honor_name, honor_date, description) VALUES
|
||||||
|
(v_account_id, v_m5, 'Ehrenmitglied', '2015-01-01', 'Für 17 Jahre als Vorsitzender'),
|
||||||
|
(v_account_id, v_m1, '20 Jahre Mitgliedschaft', '2025-01-15', 'Treueehrung'),
|
||||||
|
(v_account_id, v_m5, 'Goldene Ehrennadel', '2010-06-15', 'Verdienstauszeichnung');
|
||||||
|
|
||||||
|
-- SEPA Mandates
|
||||||
|
INSERT INTO public.sepa_mandates (account_id, member_id, mandate_reference, iban, bic, account_holder, mandate_date, status, sequence, is_primary) VALUES
|
||||||
|
(v_account_id, v_m1, 'MNDT-2020-001', 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', '2020-01-01', 'active', 'RCUR', true),
|
||||||
|
(v_account_id, v_m2, 'MNDT-2020-002', 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', '2020-01-01', 'active', 'RCUR', true);
|
||||||
|
|
||||||
|
-- Membership Applications
|
||||||
|
INSERT INTO public.membership_applications (account_id, first_name, last_name, email, phone, street, postal_code, city, date_of_birth, message, status) VALUES
|
||||||
|
(v_account_id, 'Maximilian', 'Ortner', 'max.ortner@example.at', '+43 664 9876543', 'Viaduktbogen', '6020', 'Innsbruck', '1997-08-14', 'Wurde von einem Mitglied empfohlen.', 'submitted'),
|
||||||
|
(v_account_id, 'Hannah', 'Troger', 'hannah.troger@example.at', '+43 680 8765432', 'Erlerstraße', '6020', 'Innsbruck', '2001-03-22', 'Möchte gerne der Jugendabteilung beitreten.', 'submitted'),
|
||||||
|
(v_account_id, 'Felix', 'Kirchmair', 'felix.kirchmair@example.at', '+43 660 7654321', 'Brennerstraße', '6020', 'Innsbruck', '1992-11-05', NULL, 'submitted');
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
|
"@tanstack/react-table": "catalog:",
|
||||||
"@types/papaparse": "catalog:",
|
"@types/papaparse": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
|
|||||||
@@ -6,3 +6,11 @@ export { ApplicationWorkflow } from './application-workflow';
|
|||||||
export { DuesCategoryManager } from './dues-category-manager';
|
export { DuesCategoryManager } from './dues-category-manager';
|
||||||
export { MandateManager } from './mandate-manager';
|
export { MandateManager } from './mandate-manager';
|
||||||
export { MemberImportWizard } from './member-import-wizard';
|
export { MemberImportWizard } from './member-import-wizard';
|
||||||
|
|
||||||
|
// New v2 components
|
||||||
|
export { MemberAvatar } from './member-avatar';
|
||||||
|
export { MemberStatsBar } from './member-stats-bar';
|
||||||
|
export { MembersListView } from './members-list-view';
|
||||||
|
export { MemberDetailTabs } from './member-detail-tabs';
|
||||||
|
export { MemberCreateWizard } from './member-create-wizard';
|
||||||
|
export { MemberCommandPalette } from './member-command-palette';
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from '@kit/ui/avatar';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
interface MemberAvatarProps {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
size?: 'default' | 'sm' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(firstName: string, lastName: string): string {
|
||||||
|
const f = firstName.trim().charAt(0).toUpperCase();
|
||||||
|
const l = lastName.trim().charAt(0).toUpperCase();
|
||||||
|
return `${f}${l}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorClass(firstName: string, lastName: string): string {
|
||||||
|
const name = `${firstName}${lastName}`;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const colors = [
|
||||||
|
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
|
||||||
|
'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
|
||||||
|
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
'bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300',
|
||||||
|
];
|
||||||
|
return colors[Math.abs(hash) % colors.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberAvatar({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
size = 'default',
|
||||||
|
className,
|
||||||
|
}: MemberAvatarProps) {
|
||||||
|
const initials = getInitials(firstName, lastName);
|
||||||
|
const colorClass = getColorClass(firstName, lastName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar size={size} className={cn('rounded-full', className)}>
|
||||||
|
<AvatarFallback className={cn('rounded-full font-medium', colorClass)}>
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { FileUp, Plus, User } from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@kit/ui/command';
|
||||||
|
|
||||||
|
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||||
|
import { quickSearchMembers } from '../server/actions/member-actions';
|
||||||
|
import { MemberAvatar } from './member-avatar';
|
||||||
|
|
||||||
|
interface MemberCommandPaletteProps {
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberCommandPalette({
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
}: MemberCommandPaletteProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
member_number: string | null;
|
||||||
|
status: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const { execute } = useAction(quickSearchMembers, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.data) setResults(data.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Search on query change
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.length >= 2) {
|
||||||
|
execute({ accountId, query, limit: 8 });
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}, [query, accountId, execute]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(memberId: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
router.push(`/home/${account}/members-cms/${memberId}`);
|
||||||
|
},
|
||||||
|
[router, account],
|
||||||
|
);
|
||||||
|
|
||||||
|
const basePath = `/home/${account}/members-cms`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Mitglied suchen... (Name, E-Mail, Nr.)"
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Keine Mitglieder gefunden.</CommandEmpty>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<CommandGroup heading="Mitglieder">
|
||||||
|
{results.map((m) => (
|
||||||
|
<CommandItem
|
||||||
|
key={m.id}
|
||||||
|
value={`${m.first_name} ${m.last_name} ${m.email ?? ''} ${m.member_number ?? ''}`}
|
||||||
|
onSelect={() => handleSelect(m.id)}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<MemberAvatar
|
||||||
|
firstName={m.first_name}
|
||||||
|
lastName={m.last_name}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{m.first_name} {m.last_name}
|
||||||
|
</span>
|
||||||
|
{m.member_number && (
|
||||||
|
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||||
|
Nr. {m.member_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={getMemberStatusColor(m.status)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[m.status] ?? m.status}
|
||||||
|
</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup heading="Aktionen">
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`${basePath}/new`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
Neues Mitglied erstellen
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`${basePath}/import`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileUp className="mr-2 size-4" />
|
||||||
|
Import starten
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`${basePath}/applications`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="mr-2 size-4" />
|
||||||
|
Aufnahmeanträge anzeigen
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Checkbox } from '@kit/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@kit/ui/form';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@kit/ui/select';
|
||||||
|
import { Textarea } from '@kit/ui/textarea';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { CreateMemberSchema } from '../schema/member.schema';
|
||||||
|
import { createMember } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
duesCategories: Array<{ id: string; name: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DuplicateEntry {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, title: 'Basisdaten', description: 'Name und Mitgliedschaft' },
|
||||||
|
{ id: 2, title: 'Weitere Angaben', description: 'Kontakt und Adresse' },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Mitgliedschaft & Finanzen',
|
||||||
|
description: 'Beiträge und Bankverbindung',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function MemberCreateWizard({
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
duesCategories,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [duplicates, setDuplicates] = useState<DuplicateEntry[]>([]);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateMemberSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
mobile: '',
|
||||||
|
street: '',
|
||||||
|
houseNumber: '',
|
||||||
|
postalCode: '',
|
||||||
|
city: '',
|
||||||
|
country: 'DE',
|
||||||
|
memberNumber: '',
|
||||||
|
status: 'active' as const,
|
||||||
|
entryDate: new Date().toISOString().split('T')[0]!,
|
||||||
|
dateOfBirth: '',
|
||||||
|
gender: undefined,
|
||||||
|
salutation: '',
|
||||||
|
title: '',
|
||||||
|
duesCategoryId: undefined,
|
||||||
|
iban: '',
|
||||||
|
bic: '',
|
||||||
|
accountHolder: '',
|
||||||
|
gdprConsent: false,
|
||||||
|
gdprNewsletter: false,
|
||||||
|
gdprInternet: false,
|
||||||
|
gdprPrint: false,
|
||||||
|
gdprBirthdayInfo: false,
|
||||||
|
isHonorary: false,
|
||||||
|
isFoundingMember: false,
|
||||||
|
isYouth: false,
|
||||||
|
isRetiree: false,
|
||||||
|
isProbationary: false,
|
||||||
|
guardianName: '',
|
||||||
|
guardianPhone: '',
|
||||||
|
guardianEmail: '',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(createMember, {
|
||||||
|
successMessage: 'Mitglied erstellt',
|
||||||
|
onSuccess: ({ data }: any) => {
|
||||||
|
if (data?.validationErrors) {
|
||||||
|
setDuplicates(data.validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/home/${account}/members-cms`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canProceedStep1 =
|
||||||
|
form.watch('firstName')?.trim() && form.watch('lastName')?.trim();
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step < 3) setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 1) setStep(step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
// Clean empty strings
|
||||||
|
const cleanData = { ...data };
|
||||||
|
for (const [key, value] of Object.entries(cleanData)) {
|
||||||
|
if (value === '') {
|
||||||
|
(cleanData as any)[key] = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execute(cleanData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<nav className="flex items-center justify-center gap-2">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (s.id < step) setStep(s.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
step === s.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: step > s.id
|
||||||
|
? 'bg-primary/10 text-primary cursor-pointer'
|
||||||
|
: 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step > s.id ? (
|
||||||
|
<Check className="size-4" />
|
||||||
|
) : (
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full text-xs">
|
||||||
|
{s.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{s.title}</span>
|
||||||
|
</button>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-px w-8',
|
||||||
|
step > s.id ? 'bg-primary' : 'bg-border',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Step 1: Basisdaten */}
|
||||||
|
{step === 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Basisdaten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vorname *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
data-test="member-first-name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nachname *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} data-test="member-last-name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>E-Mail</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
{...field}
|
||||||
|
data-test="member-email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="memberNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mitgliedsnr.</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} data-test="member-number" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger data-test="member-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Aktiv</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inaktiv</SelectItem>
|
||||||
|
<SelectItem value="pending">Ausstehend</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="entryDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Eintrittsdatum</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Weitere Angaben */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kontakt</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Telefon</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mobile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mobil</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adresse</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="street"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="sm:col-span-2">
|
||||||
|
<FormLabel>Straße</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="houseNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Hausnr.</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="postalCode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>PLZ</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="city"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="sm:col-span-2">
|
||||||
|
<FormLabel>Ort</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Persönliche Daten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateOfBirth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Geburtsdatum</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gender"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Geschlecht</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="—" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="male">Männlich</SelectItem>
|
||||||
|
<SelectItem value="female">Weiblich</SelectItem>
|
||||||
|
<SelectItem value="diverse">Divers</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="salutation"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Anrede</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="z.B. Herr, Frau" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Titel</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="z.B. Dr., Prof." />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Mitgliedschaft & Finanzen */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Beitrag</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{duesCategories.length > 0 && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="duesCategoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Beitragskategorie</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Keine" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{duesCategories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.amount.toFixed(2)} EUR)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bankverbindung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="iban"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>IBAN</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} data-test="member-iban" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bic"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>BIC</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accountHolder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Kontoinhaber</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Merkmale</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{ name: 'isHonorary' as const, label: 'Ehrenmitglied' },
|
||||||
|
{
|
||||||
|
name: 'isFoundingMember' as const,
|
||||||
|
label: 'Gründungsmitglied',
|
||||||
|
},
|
||||||
|
{ name: 'isYouth' as const, label: 'Jugendmitglied' },
|
||||||
|
{ name: 'isRetiree' as const, label: 'Senior' },
|
||||||
|
{ name: 'isProbationary' as const, label: 'Probezeit' },
|
||||||
|
].map(({ name, label }) => (
|
||||||
|
<FormField
|
||||||
|
key={name}
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>DSGVO-Einwilligungen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
name: 'gdprConsent' as const,
|
||||||
|
label: 'Allgemeine Einwilligung',
|
||||||
|
},
|
||||||
|
{ name: 'gdprNewsletter' as const, label: 'Newsletter' },
|
||||||
|
{
|
||||||
|
name: 'gdprInternet' as const,
|
||||||
|
label: 'Internetveröffentlichung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gdprPrint' as const,
|
||||||
|
label: 'Printveröffentlichung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gdprBirthdayInfo' as const,
|
||||||
|
label: 'Geburtstagsinfo',
|
||||||
|
},
|
||||||
|
].map(({ name, label }) => (
|
||||||
|
<FormField
|
||||||
|
key={name}
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notizen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Interne Anmerkungen..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between pt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={step === 1 ? () => router.back() : handleBack}
|
||||||
|
>
|
||||||
|
{step === 1 ? 'Abbrechen' : 'Zurück'}
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{step < 3 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={step === 1 && !canProceedStep1}
|
||||||
|
onClick={() => handleSubmit()}
|
||||||
|
data-test="wizard-skip-submit"
|
||||||
|
>
|
||||||
|
Direkt speichern
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step < 3 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={step === 1 && !canProceedStep1}
|
||||||
|
onClick={handleNext}
|
||||||
|
data-test="wizard-next"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
data-test="wizard-submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Speichere...' : 'Mitglied erstellen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* Duplicate dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={duplicates.length > 0}
|
||||||
|
onOpenChange={() => setDuplicates([])}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mögliche Duplikate</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Folgende ähnliche Mitglieder wurden gefunden:
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<ul className="list-disc space-y-1 pl-6 text-sm">
|
||||||
|
{duplicates.map((d, i) => (
|
||||||
|
<li key={i}>{d.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setDuplicates([]);
|
||||||
|
// Force submit ignoring duplicates — would need a flag
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Trotzdem erstellen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ArrowLeft, Mail, Pencil } from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||||
|
import { deleteMember, updateMember } from '../server/actions/member-actions';
|
||||||
|
import { MemberAvatar } from './member-avatar';
|
||||||
|
|
||||||
|
interface MemberDetailHeaderProps {
|
||||||
|
member: Record<string, unknown>;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberDetailHeader({
|
||||||
|
member,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
}: MemberDetailHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const memberId = String(member.id);
|
||||||
|
const firstName = String(member.first_name ?? '');
|
||||||
|
const lastName = String(member.last_name ?? '');
|
||||||
|
const status = String(member.status ?? 'active');
|
||||||
|
const email = member.email ? String(member.email) : null;
|
||||||
|
const memberNumber = member.member_number
|
||||||
|
? String(member.member_number)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const { execute: executeDelete, isPending: isDeleting } = useAction(
|
||||||
|
deleteMember,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Mitglied wurde gekündigt');
|
||||||
|
router.push(`/home/${account}/members-cms`);
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Kündigen'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeArchive, isPending: isArchiving } = useAction(
|
||||||
|
updateMember,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Mitglied wurde archiviert');
|
||||||
|
setArchiveDialogOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Archivieren'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => router.push(`/home/${account}/members-cms`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1 size-4" />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<MemberAvatar
|
||||||
|
firstName={firstName}
|
||||||
|
lastName={lastName}
|
||||||
|
size="lg"
|
||||||
|
className="size-14"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{firstName} {lastName}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{memberNumber && (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Nr. {memberNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
{Boolean(member.is_honorary) && (
|
||||||
|
<Badge variant="outline">Ehrenmitglied</Badge>
|
||||||
|
)}
|
||||||
|
{Boolean(member.is_founding_member) && (
|
||||||
|
<Badge variant="outline">Gründungsmitglied</Badge>
|
||||||
|
)}
|
||||||
|
{Boolean(member.is_youth) && (
|
||||||
|
<Badge variant="outline">Jugend</Badge>
|
||||||
|
)}
|
||||||
|
{Boolean(member.is_retiree) && (
|
||||||
|
<Badge variant="outline">Senior</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
E-Mail
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/${memberId}/edit`}
|
||||||
|
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1 rounded-lg border px-2.5 text-sm font-medium transition-colors">
|
||||||
|
Aktionen
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setArchiveDialogOpen(true)}
|
||||||
|
disabled={isArchiving}
|
||||||
|
>
|
||||||
|
Archivieren
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Kündigen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive confirmation */}
|
||||||
|
<AlertDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mitglied archivieren</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Möchten Sie {firstName} {lastName} wirklich archivieren?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() =>
|
||||||
|
executeArchive({ memberId, accountId, isArchived: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Archivieren
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mitglied kündigen</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Möchten Sie {firstName} {lastName} wirklich kündigen? Das Mitglied
|
||||||
|
wird als "Ausgetreten" markiert.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => executeDelete({ memberId, accountId })}
|
||||||
|
>
|
||||||
|
Kündigen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
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 { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATUS_LABELS,
|
||||||
|
getMemberStatusColor,
|
||||||
|
formatIban,
|
||||||
|
computeAge,
|
||||||
|
computeMembershipYears,
|
||||||
|
} from '../lib/member-utils';
|
||||||
|
import {
|
||||||
|
createMemberRole,
|
||||||
|
deleteMemberRole,
|
||||||
|
createMemberHonor,
|
||||||
|
deleteMemberHonor,
|
||||||
|
createMandate,
|
||||||
|
revokeMandate,
|
||||||
|
} from '../server/actions/member-actions';
|
||||||
|
import { MemberDetailHeader } from './member-detail-header';
|
||||||
|
|
||||||
|
interface MemberRole {
|
||||||
|
id: string;
|
||||||
|
role_name: string;
|
||||||
|
from_date: string | null;
|
||||||
|
until_date: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberHonor {
|
||||||
|
id: string;
|
||||||
|
honor_name: string;
|
||||||
|
honor_date: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SepaMandate {
|
||||||
|
id: string;
|
||||||
|
mandate_reference: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string | null;
|
||||||
|
account_holder: string;
|
||||||
|
mandate_date: string;
|
||||||
|
status: string;
|
||||||
|
is_primary: boolean;
|
||||||
|
sequence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberDetailTabsProps {
|
||||||
|
member: Record<string, unknown>;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
roles?: MemberRole[];
|
||||||
|
honors?: MemberHonor[];
|
||||||
|
mandates?: SepaMandate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b py-2 last:border-b-0">
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">{label}</span>
|
||||||
|
<span className="text-right text-sm">{value ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberDetailTabs({
|
||||||
|
member,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
roles = [],
|
||||||
|
honors = [],
|
||||||
|
mandates = [],
|
||||||
|
}: MemberDetailTabsProps) {
|
||||||
|
const memberId = String(member.id);
|
||||||
|
const status = String(member.status ?? 'active');
|
||||||
|
const age = computeAge(member.date_of_birth as string | null);
|
||||||
|
const membershipYears = computeMembershipYears(
|
||||||
|
member.entry_date as string | null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<MemberDetailHeader
|
||||||
|
member={member}
|
||||||
|
account={account}
|
||||||
|
accountId={accountId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="stammdaten">
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="stammdaten">Stammdaten</TabsTrigger>
|
||||||
|
<TabsTrigger value="mitgliedschaft">Mitgliedschaft</TabsTrigger>
|
||||||
|
<TabsTrigger value="finanzen">Finanzen</TabsTrigger>
|
||||||
|
<TabsTrigger value="funktionen">
|
||||||
|
Funktionen & Ehrungen
|
||||||
|
{(roles.length > 0 || honors.length > 0) && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-1.5 h-5 min-w-5 px-1 text-xs"
|
||||||
|
>
|
||||||
|
{roles.length + honors.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="datenschutz">Datenschutz</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Tab 1: Stammdaten */}
|
||||||
|
<TabsContent value="stammdaten">
|
||||||
|
<div className="grid gap-6 pt-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Persönliche Daten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow
|
||||||
|
label="Vorname"
|
||||||
|
value={String(member.first_name ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Nachname"
|
||||||
|
value={String(member.last_name ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Anrede"
|
||||||
|
value={String(member.salutation ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Titel" value={String(member.title ?? '—')} />
|
||||||
|
<DetailRow
|
||||||
|
label="Geburtsdatum"
|
||||||
|
value={
|
||||||
|
member.date_of_birth
|
||||||
|
? `${formatDate(String(member.date_of_birth))}${age !== null ? ` (${age} Jahre)` : ''}`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Geburtsort"
|
||||||
|
value={String(member.birthplace ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Geschlecht"
|
||||||
|
value={String(member.gender ?? '—')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kontakt</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow label="E-Mail" value={String(member.email ?? '—')} />
|
||||||
|
<DetailRow
|
||||||
|
label="Telefon"
|
||||||
|
value={String(member.phone ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Mobil" value={String(member.mobile ?? '—')} />
|
||||||
|
<DetailRow
|
||||||
|
label="Telefon 2"
|
||||||
|
value={String(member.phone2 ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Fax" value={String(member.fax ?? '—')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adresse</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow
|
||||||
|
label="Straße"
|
||||||
|
value={
|
||||||
|
member.street
|
||||||
|
? `${member.street}${member.house_number ? ` ${member.house_number}` : ''}`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Boolean(member.street2) && (
|
||||||
|
<DetailRow
|
||||||
|
label="Adresszusatz"
|
||||||
|
value={String(member.street2)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DetailRow
|
||||||
|
label="PLZ / Ort"
|
||||||
|
value={
|
||||||
|
member.postal_code || member.city
|
||||||
|
? `${member.postal_code ?? ''} ${member.city ?? ''}`.trim()
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Land"
|
||||||
|
value={String(member.country ?? 'DE')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Guardian info for youth members */}
|
||||||
|
{Boolean(member.is_youth) && (
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Erziehungsberechtigte/r</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow
|
||||||
|
label="Name"
|
||||||
|
value={String(member.guardian_name ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Telefon"
|
||||||
|
value={String(member.guardian_phone ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="E-Mail"
|
||||||
|
value={String(member.guardian_email ?? '—')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 2: Mitgliedschaft */}
|
||||||
|
<TabsContent value="mitgliedschaft">
|
||||||
|
<div className="grid gap-6 pt-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mitgliedschaft</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow
|
||||||
|
label="Mitgliedsnr."
|
||||||
|
value={String(member.member_number ?? '—')}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Eintrittsdatum"
|
||||||
|
value={
|
||||||
|
member.entry_date
|
||||||
|
? formatDate(String(member.entry_date))
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Mitgliedsjahre"
|
||||||
|
value={
|
||||||
|
membershipYears > 0
|
||||||
|
? `${membershipYears} Jahre`
|
||||||
|
: '< 1 Jahr'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Boolean(member.exit_date) && (
|
||||||
|
<>
|
||||||
|
<DetailRow
|
||||||
|
label="Austrittsdatum"
|
||||||
|
value={formatDate(String(member.exit_date))}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Austrittsgrund"
|
||||||
|
value={String(member.exit_reason ?? '—')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Merkmale</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
{Boolean(member.is_honorary) && <Badge>Ehrenmitglied</Badge>}
|
||||||
|
{Boolean(member.is_founding_member) && (
|
||||||
|
<Badge>Gründungsmitglied</Badge>
|
||||||
|
)}
|
||||||
|
{Boolean(member.is_youth) && <Badge>Jugend</Badge>}
|
||||||
|
{Boolean(member.is_retiree) && <Badge>Senior</Badge>}
|
||||||
|
{Boolean(member.is_probationary) && (
|
||||||
|
<Badge variant="outline">Probezeit</Badge>
|
||||||
|
)}
|
||||||
|
{Boolean(member.is_transferred) && (
|
||||||
|
<Badge variant="outline">Überweisung</Badge>
|
||||||
|
)}
|
||||||
|
{!member.is_honorary &&
|
||||||
|
!member.is_founding_member &&
|
||||||
|
!member.is_youth &&
|
||||||
|
!member.is_retiree &&
|
||||||
|
!member.is_probationary &&
|
||||||
|
!member.is_transferred && (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Keine besonderen Merkmale
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Boolean(member.notes) && (
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
|
Notizen
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{String(member.notes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 3: Finanzen */}
|
||||||
|
<TabsContent value="finanzen">
|
||||||
|
<div className="space-y-6 pt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bankverbindung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow
|
||||||
|
label="IBAN"
|
||||||
|
value={formatIban(member.iban as string | null)}
|
||||||
|
/>
|
||||||
|
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
|
||||||
|
<DetailRow
|
||||||
|
label="Kontoinhaber"
|
||||||
|
value={String(member.account_holder ?? '—')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MandatesSection
|
||||||
|
mandates={mandates}
|
||||||
|
memberId={memberId}
|
||||||
|
accountId={accountId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 4: Funktionen & Ehrungen */}
|
||||||
|
<TabsContent value="funktionen">
|
||||||
|
<div className="space-y-6 pt-4">
|
||||||
|
<RolesSection
|
||||||
|
roles={roles}
|
||||||
|
memberId={memberId}
|
||||||
|
accountId={accountId}
|
||||||
|
/>
|
||||||
|
<HonorsSection
|
||||||
|
honors={honors}
|
||||||
|
memberId={memberId}
|
||||||
|
accountId={accountId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 5: Datenschutz */}
|
||||||
|
<TabsContent value="datenschutz">
|
||||||
|
<div className="grid gap-6 pt-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>DSGVO-Einwilligungen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ConsentRow
|
||||||
|
label="Allgemeine Einwilligung"
|
||||||
|
value={Boolean(member.gdpr_consent)}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label="Newsletter"
|
||||||
|
value={Boolean(member.gdpr_newsletter)}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label="Internetveröffentlichung"
|
||||||
|
value={Boolean(member.gdpr_internet)}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label="Printveröffentlichung"
|
||||||
|
value={Boolean(member.gdpr_print)}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label="Geburtstagsinfo"
|
||||||
|
value={Boolean(member.gdpr_birthday_info)}
|
||||||
|
/>
|
||||||
|
{Boolean(member.gdpr_consent_date) && (
|
||||||
|
<DetailRow
|
||||||
|
label="Einwilligungsdatum"
|
||||||
|
value={formatDate(String(member.gdpr_consent_date))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Datenqualität</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ConsentRow
|
||||||
|
label="Adresse gültig"
|
||||||
|
value={!member.address_invalid}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label="Datenabgleich nötig"
|
||||||
|
value={Boolean(member.data_reconciliation_needed)}
|
||||||
|
invert
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Online-Zugang"
|
||||||
|
value={member.user_id ? 'Verknüpft' : 'Nicht verknüpft'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Zugang gesperrt"
|
||||||
|
value={member.online_access_blocked ? 'Ja' : 'Nein'}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Consent Row ─── */
|
||||||
|
|
||||||
|
function ConsentRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
invert,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
invert?: boolean;
|
||||||
|
}) {
|
||||||
|
const isGood = invert ? !value : value;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b py-2 last:border-b-0">
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">{label}</span>
|
||||||
|
<Badge variant={isGood ? 'default' : 'secondary'}>
|
||||||
|
{value ? 'Ja' : 'Nein'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Roles Section ─── */
|
||||||
|
|
||||||
|
function RolesSection({
|
||||||
|
roles,
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
}: {
|
||||||
|
roles: MemberRole[];
|
||||||
|
memberId: string;
|
||||||
|
accountId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [roleName, setRoleName] = useState('');
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [untilDate, setUntilDate] = useState('');
|
||||||
|
|
||||||
|
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
|
||||||
|
createMemberRole,
|
||||||
|
{
|
||||||
|
successMessage: 'Funktion erstellt',
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setRoleName('');
|
||||||
|
setFromDate('');
|
||||||
|
setUntilDate('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeDeleteRole } = useActionWithToast(deleteMemberRole, {
|
||||||
|
successMessage: 'Funktion gelöscht',
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Funktionen</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
data-test="add-role-btn"
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Funktion'}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{showForm && (
|
||||||
|
<div className="mb-4 space-y-3 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Bezeichnung</Label>
|
||||||
|
<Input
|
||||||
|
value={roleName}
|
||||||
|
onChange={(e) => setRoleName(e.target.value)}
|
||||||
|
placeholder="z.B. Kassier"
|
||||||
|
data-test="role-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">Von</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
onChange={(e) => setFromDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">Bis</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={untilDate}
|
||||||
|
onChange={(e) => setUntilDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!roleName || isCreating}
|
||||||
|
onClick={() =>
|
||||||
|
executeCreate({
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
roleName,
|
||||||
|
fromDate: fromDate || undefined,
|
||||||
|
untilDate: untilDate || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-test="save-role-btn"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roles.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{role.role_name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{role.from_date ? formatDate(role.from_date) : '—'}
|
||||||
|
{' — '}
|
||||||
|
{role.until_date ? formatDate(role.until_date) : 'heute'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-8"
|
||||||
|
onClick={() => executeDeleteRole({ roleId: role.id })}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Keine Funktionen zugewiesen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Honors Section ─── */
|
||||||
|
|
||||||
|
function HonorsSection({
|
||||||
|
honors,
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
}: {
|
||||||
|
honors: MemberHonor[];
|
||||||
|
memberId: string;
|
||||||
|
accountId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [honorName, setHonorName] = useState('');
|
||||||
|
const [honorDate, setHonorDate] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
|
||||||
|
createMemberHonor,
|
||||||
|
{
|
||||||
|
successMessage: 'Ehrung erstellt',
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setHonorName('');
|
||||||
|
setHonorDate('');
|
||||||
|
setDescription('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeDeleteHonor } = useActionWithToast(
|
||||||
|
deleteMemberHonor,
|
||||||
|
{
|
||||||
|
successMessage: 'Ehrung gelöscht',
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Ehrungen</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
data-test="add-honor-btn"
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Ehrung'}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{showForm && (
|
||||||
|
<div className="mb-4 space-y-3 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Bezeichnung</Label>
|
||||||
|
<Input
|
||||||
|
value={honorName}
|
||||||
|
onChange={(e) => setHonorName(e.target.value)}
|
||||||
|
placeholder="z.B. 25 Jahre Mitgliedschaft"
|
||||||
|
data-test="honor-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Datum</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={honorDate}
|
||||||
|
onChange={(e) => setHonorDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Beschreibung</Label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!honorName || isCreating}
|
||||||
|
onClick={() =>
|
||||||
|
executeCreate({
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
honorName,
|
||||||
|
honorDate: honorDate || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-test="save-honor-btn"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{honors.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{honors.map((honor) => (
|
||||||
|
<div
|
||||||
|
key={honor.id}
|
||||||
|
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{honor.honor_name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{honor.honor_date ? formatDate(honor.honor_date) : '—'}
|
||||||
|
{honor.description && ` — ${honor.description}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-8"
|
||||||
|
onClick={() => executeDeleteHonor({ honorId: honor.id })}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Keine Ehrungen vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mandates Section ─── */
|
||||||
|
|
||||||
|
function MandatesSection({
|
||||||
|
mandates,
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
}: {
|
||||||
|
mandates: SepaMandate[];
|
||||||
|
memberId: string;
|
||||||
|
accountId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [mandateRef, setMandateRef] = useState('');
|
||||||
|
const [iban, setIban] = useState('');
|
||||||
|
const [bic, setBic] = useState('');
|
||||||
|
const [holder, setHolder] = useState('');
|
||||||
|
const [mandateDate, setMandateDate] = useState(
|
||||||
|
new Date().toISOString().split('T')[0]!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
|
||||||
|
createMandate,
|
||||||
|
{
|
||||||
|
successMessage: 'Mandat erstellt',
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setMandateRef('');
|
||||||
|
setIban('');
|
||||||
|
setBic('');
|
||||||
|
setHolder('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeRevoke } = useActionWithToast(revokeMandate, {
|
||||||
|
successMessage: 'Mandat widerrufen',
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>SEPA-Mandate</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
data-test="add-mandate-btn"
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Mandat'}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{showForm && (
|
||||||
|
<div className="mb-4 space-y-3 rounded-lg border p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Mandatsreferenz</Label>
|
||||||
|
<Input
|
||||||
|
value={mandateRef}
|
||||||
|
onChange={(e) => setMandateRef(e.target.value)}
|
||||||
|
data-test="mandate-ref-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Datum</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={mandateDate}
|
||||||
|
onChange={(e) => setMandateDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">IBAN</Label>
|
||||||
|
<Input
|
||||||
|
value={iban}
|
||||||
|
onChange={(e) => setIban(e.target.value)}
|
||||||
|
data-test="mandate-iban-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">BIC</Label>
|
||||||
|
<Input value={bic} onChange={(e) => setBic(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Kontoinhaber</Label>
|
||||||
|
<Input
|
||||||
|
value={holder}
|
||||||
|
onChange={(e) => setHolder(e.target.value)}
|
||||||
|
data-test="mandate-holder-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!mandateRef || !iban || !holder || isCreating}
|
||||||
|
onClick={() =>
|
||||||
|
executeCreate({
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
mandateReference: mandateRef,
|
||||||
|
iban,
|
||||||
|
bic: bic || undefined,
|
||||||
|
accountHolder: holder,
|
||||||
|
mandateDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-test="save-mandate-btn"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mandates.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mandates.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{m.mandate_reference}</p>
|
||||||
|
<Badge
|
||||||
|
variant={m.status === 'active' ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{m.status}
|
||||||
|
</Badge>
|
||||||
|
{m.is_primary && <Badge variant="outline">Primär</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatIban(m.iban)} — {m.account_holder}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{m.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-8"
|
||||||
|
onClick={() => executeRevoke({ mandateId: m.id })}
|
||||||
|
>
|
||||||
|
Widerrufen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Keine SEPA-Mandate vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
ExternalLink,
|
||||||
|
Hash,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@kit/ui/select';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@kit/ui/sheet';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATUS_LABELS,
|
||||||
|
getMemberStatusColor,
|
||||||
|
computeMembershipYears,
|
||||||
|
} from '../lib/member-utils';
|
||||||
|
import { updateMember } from '../server/actions/member-actions';
|
||||||
|
import { MemberAvatar } from './member-avatar';
|
||||||
|
import type { MemberRow } from './members-table-columns';
|
||||||
|
|
||||||
|
interface MemberQuickPreviewProps {
|
||||||
|
member: MemberRow | null;
|
||||||
|
account: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberQuickPreview({
|
||||||
|
member,
|
||||||
|
account,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: MemberQuickPreviewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { execute: executeStatusChange } = useAction(updateMember, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Status aktualisiert');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-[400px] sm:w-[440px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="sr-only">Mitglied Vorschau</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 pt-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<MemberAvatar
|
||||||
|
firstName={member.firstName}
|
||||||
|
lastName={member.lastName}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-lg font-semibold">
|
||||||
|
{member.firstName} {member.lastName}
|
||||||
|
</h3>
|
||||||
|
{member.memberNumber && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Nr. {member.memberNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant={getMemberStatusColor(member.status)}>
|
||||||
|
{STATUS_LABELS[member.status] ?? member.status}
|
||||||
|
</Badge>
|
||||||
|
{member.isHonorary && (
|
||||||
|
<Badge variant="outline">Ehrenmitglied</Badge>
|
||||||
|
)}
|
||||||
|
{member.isFoundingMember && (
|
||||||
|
<Badge variant="outline">Gründungsmitglied</Badge>
|
||||||
|
)}
|
||||||
|
{member.isYouth && <Badge variant="outline">Jugend</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick status change */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-muted-foreground text-xs font-medium">
|
||||||
|
Status ändern
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={member.status}
|
||||||
|
onValueChange={(status) => {
|
||||||
|
if (status && status !== member.status) {
|
||||||
|
executeStatusChange({
|
||||||
|
memberId: member.id,
|
||||||
|
status: status as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Aktiv</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inaktiv</SelectItem>
|
||||||
|
<SelectItem value="pending">Ausstehend</SelectItem>
|
||||||
|
<SelectItem value="resigned">Ausgetreten</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{member.email && (
|
||||||
|
<InfoRow icon={Mail} label="E-Mail">
|
||||||
|
<a
|
||||||
|
href={`mailto:${member.email}`}
|
||||||
|
className="text-primary truncate hover:underline"
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
</a>
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
{(member.phone || member.mobile) && (
|
||||||
|
<InfoRow icon={Phone} label="Telefon">
|
||||||
|
{member.phone || member.mobile}
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
{member.city && (
|
||||||
|
<InfoRow icon={MapPin} label="Ort">
|
||||||
|
{member.city}
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
{member.entryDate && (
|
||||||
|
<InfoRow icon={Calendar} label="Eintritt">
|
||||||
|
{formatDate(member.entryDate)} (
|
||||||
|
{computeMembershipYears(member.entryDate)} Jahre)
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
{member.memberNumber && (
|
||||||
|
<InfoRow icon={Hash} label="Mitgliedsnr.">
|
||||||
|
{member.memberNumber}
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 border-t pt-4">
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/${member.id}`}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors"
|
||||||
|
data-test="preview-view-full"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
Vollansicht
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/${member.id}/edit`}
|
||||||
|
className="border-border bg-background hover:bg-muted inline-flex flex-1 items-center justify-center rounded-lg border px-2.5 py-1.5 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-muted-foreground text-xs">{label}</p>
|
||||||
|
<p className="truncate text-sm">{children}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
interface QuickStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
pending: number;
|
||||||
|
newThisYear: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberStatsBarProps {
|
||||||
|
stats: QuickStats;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="min-w-0 flex-1">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-muted-foreground truncate text-xs">{label}</p>
|
||||||
|
<p className={cn('text-2xl font-semibold tabular-nums', accent)}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberStatsBar({ stats, className }: MemberStatsBarProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('grid grid-cols-2 gap-3 md:grid-cols-4', className)}>
|
||||||
|
<StatCard label="Gesamt" value={stats.total} />
|
||||||
|
<StatCard
|
||||||
|
label="Aktiv"
|
||||||
|
value={stats.active}
|
||||||
|
accent="text-green-600 dark:text-green-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Ausstehend"
|
||||||
|
value={stats.pending}
|
||||||
|
accent="text-amber-600 dark:text-amber-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Neu dieses Jahr"
|
||||||
|
value={stats.newThisYear}
|
||||||
|
accent="text-blue-600 dark:text-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Checkbox } from '@kit/ui/checkbox';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@kit/ui/select';
|
||||||
|
|
||||||
|
interface MembersFilterPanelProps {
|
||||||
|
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||||
|
duesCategories: Array<{ id: string; name: string }>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAG_OPTIONS = [
|
||||||
|
{ key: 'honorary', label: 'Ehrenmitglied' },
|
||||||
|
{ key: 'founding', label: 'Gründungsmitglied' },
|
||||||
|
{ key: 'youth', label: 'Jugend' },
|
||||||
|
{ key: 'retiree', label: 'Senior' },
|
||||||
|
{ key: 'probationary', label: 'Probezeit' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function MembersFilterPanel({
|
||||||
|
departments,
|
||||||
|
duesCategories,
|
||||||
|
onClose,
|
||||||
|
}: MembersFilterPanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const applyFilter = useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
params.delete('page');
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push('?');
|
||||||
|
onClose();
|
||||||
|
}, [router, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">Erweiterte Filter</h4>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAllFilters}>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Department */}
|
||||||
|
{departments.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Abteilung</Label>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get('departmentId') ?? ''}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
applyFilter('departmentId', v === 'all' ? null : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="Alle Abteilungen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Alle Abteilungen</SelectItem>
|
||||||
|
{departments.map((d) => (
|
||||||
|
<SelectItem key={d.id} value={d.id}>
|
||||||
|
{d.name} ({d.memberCount})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dues Category */}
|
||||||
|
{duesCategories.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Beitragskategorie</Label>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get('duesCategoryId') ?? ''}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
applyFilter('duesCategoryId', v === 'all' ? null : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="Alle Kategorien" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Alle Kategorien</SelectItem>
|
||||||
|
{duesCategories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entry date range */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Eintrittsdatum</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={searchParams.get('entryDateFrom') ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
applyFilter('entryDateFrom', e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="Von"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={searchParams.get('entryDateTo') ?? ''}
|
||||||
|
onChange={(e) => applyFilter('entryDateTo', e.target.value || null)}
|
||||||
|
placeholder="Bis"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Merkmale</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{FLAG_OPTIONS.map((flag) => {
|
||||||
|
const currentFlags = searchParams.get('flags')?.split(',') ?? [];
|
||||||
|
const isChecked = currentFlags.includes(flag.key);
|
||||||
|
return (
|
||||||
|
<div key={flag.key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`flag-${flag.key}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newFlags = checked
|
||||||
|
? [...currentFlags, flag.key]
|
||||||
|
: currentFlags.filter((f) => f !== flag.key);
|
||||||
|
const filtered = newFlags.filter(Boolean);
|
||||||
|
applyFilter(
|
||||||
|
'flags',
|
||||||
|
filtered.length > 0 ? filtered.join(',') : null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`flag-${flag.key}`} className="text-xs">
|
||||||
|
{flag.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RowSelectionState,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
|
||||||
|
import { MemberQuickPreview } from './member-quick-preview';
|
||||||
|
import { createMembersColumns, type MemberRow } from './members-table-columns';
|
||||||
|
import { MembersToolbar } from './members-toolbar';
|
||||||
|
|
||||||
|
interface MembersListViewProps {
|
||||||
|
data: Array<Record<string, unknown>>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
duesCategories: Array<{ id: string; name: string }>;
|
||||||
|
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersListView({
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
duesCategories,
|
||||||
|
departments,
|
||||||
|
}: MembersListViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [previewMember, setPreviewMember] = useState<MemberRow | null>(null);
|
||||||
|
|
||||||
|
const members: MemberRow[] = data.map((m) => ({
|
||||||
|
id: String(m.id),
|
||||||
|
firstName: String(m.first_name ?? ''),
|
||||||
|
lastName: String(m.last_name ?? ''),
|
||||||
|
email: String(m.email ?? ''),
|
||||||
|
phone: String(m.phone ?? ''),
|
||||||
|
mobile: String(m.mobile ?? ''),
|
||||||
|
memberNumber: String(m.member_number ?? ''),
|
||||||
|
city: String(m.city ?? ''),
|
||||||
|
status: String(m.status ?? 'active'),
|
||||||
|
entryDate: String(m.entry_date ?? ''),
|
||||||
|
isHonorary: Boolean(m.is_honorary),
|
||||||
|
isFoundingMember: Boolean(m.is_founding_member),
|
||||||
|
isYouth: Boolean(m.is_youth),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns = createMembersColumns({
|
||||||
|
account,
|
||||||
|
onPreview: setPreviewMember,
|
||||||
|
onNavigate: (path) => router.push(path),
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: members,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: { rowSelection },
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: Math.ceil(total / pageSize),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedIds = Object.keys(rowSelection).filter(
|
||||||
|
(key) => rowSelection[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSearchParam = useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
if (key !== 'page') params.delete('page');
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFilters = searchParams.get('q') || searchParams.get('status');
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<MembersToolbar
|
||||||
|
account={account}
|
||||||
|
accountId={accountId}
|
||||||
|
searchValue={searchParams.get('q') ?? ''}
|
||||||
|
statusFilter={searchParams.get('status') ?? ''}
|
||||||
|
onSearchChange={(value) => updateSearchParam('q', value || null)}
|
||||||
|
onStatusChange={(value) => updateSearchParam('status', value || null)}
|
||||||
|
selectedCount={selectedIds.length}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
departments={departments}
|
||||||
|
duesCategories={duesCategories}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Table or empty state */}
|
||||||
|
{total === 0 && !hasFilters ? (
|
||||||
|
<EmptyState account={account} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40 sticky top-0 z-10">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id} className="whitespace-nowrap">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length > 0 ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
|
className="cursor-pointer transition-colors"
|
||||||
|
onClick={() => setPreviewMember(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-muted-foreground h-32 text-center"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">Keine Mitglieder gefunden</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Versuchen Sie andere Suchbegriffe oder Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm tabular-nums">
|
||||||
|
{total} Mitglieder
|
||||||
|
{selectedIds.length > 0 && ` — ${selectedIds.length} ausgewählt`}
|
||||||
|
</p>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => updateSearchParam('page', String(page - 1))}
|
||||||
|
data-test="members-prev-page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums">
|
||||||
|
Seite {page} von {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => updateSearchParam('page', String(page + 1))}
|
||||||
|
data-test="members-next-page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick preview sheet */}
|
||||||
|
<MemberQuickPreview
|
||||||
|
member={previewMember}
|
||||||
|
account={account}
|
||||||
|
open={!!previewMember}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPreviewMember(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Empty State ─── */
|
||||||
|
|
||||||
|
function EmptyState({ account }: { account: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
|
||||||
|
<div className="bg-muted mb-4 flex size-16 items-center justify-center rounded-full">
|
||||||
|
<Users className="text-muted-foreground size-8" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">Noch keine Mitglieder</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-center text-sm">
|
||||||
|
Erstellen Sie Ihr erstes Mitglied oder importieren Sie bestehende
|
||||||
|
Mitgliederdaten aus einer CSV-Datei.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/new`}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||||
|
data-test="empty-create-member"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Mitglied erstellen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/import`}
|
||||||
|
className="border-border bg-background hover:bg-muted inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
CSV importieren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { MoreHorizontal, Mail, Eye, Pencil, Archive } from 'lucide-react';
|
||||||
|
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Checkbox } from '@kit/ui/checkbox';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
|
||||||
|
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||||
|
import { MemberAvatar } from './member-avatar';
|
||||||
|
|
||||||
|
export interface MemberRow {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
mobile: string;
|
||||||
|
memberNumber: string;
|
||||||
|
city: string;
|
||||||
|
status: string;
|
||||||
|
entryDate: string;
|
||||||
|
isHonorary: boolean;
|
||||||
|
isFoundingMember: boolean;
|
||||||
|
isYouth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnOptions {
|
||||||
|
account: string;
|
||||||
|
onPreview: (member: MemberRow) => void;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMembersColumns({
|
||||||
|
account,
|
||||||
|
onPreview,
|
||||||
|
onNavigate,
|
||||||
|
}: ColumnOptions): ColumnDef<MemberRow>[] {
|
||||||
|
return [
|
||||||
|
// Checkbox column
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Alle auswählen"
|
||||||
|
data-test="members-select-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Auswählen"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
size: 40,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Avatar + Name column
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const m = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MemberAvatar
|
||||||
|
firstName={m.firstName}
|
||||||
|
lastName={m.lastName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{m.lastName}, {m.firstName}
|
||||||
|
</p>
|
||||||
|
{m.memberNumber && (
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
Nr. {m.memberNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 220,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Contact column
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
header: 'Kontakt',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const m = row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{m.email && (
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{m.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(m.phone || m.mobile) && (
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{m.phone || m.mobile}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!m.email && !m.phone && !m.mobile && (
|
||||||
|
<span className="text-muted-foreground/50 text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 200,
|
||||||
|
},
|
||||||
|
|
||||||
|
// City
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'Ort',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{String(getValue() ?? '') || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Status with color dot
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = String(getValue());
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`size-2 rounded-full ${
|
||||||
|
status === 'active'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: status === 'pending'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: status === 'inactive'
|
||||||
|
? 'bg-gray-400'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 130,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Entry date
|
||||||
|
{
|
||||||
|
accessorKey: 'entryDate',
|
||||||
|
header: 'Eintritt',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const date = String(getValue());
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums">
|
||||||
|
{date ? formatDate(date) : '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
{
|
||||||
|
id: 'flags',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const m = row.original;
|
||||||
|
const flags = [];
|
||||||
|
if (m.isHonorary) flags.push({ key: 'E', label: 'Ehrenmitglied' });
|
||||||
|
if (m.isFoundingMember)
|
||||||
|
flags.push({ key: 'G', label: 'Gründungsmitglied' });
|
||||||
|
if (m.isYouth) flags.push({ key: 'J', label: 'Jugend' });
|
||||||
|
|
||||||
|
if (flags.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{flags.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.key}
|
||||||
|
title={f.label}
|
||||||
|
className="bg-muted text-muted-foreground inline-flex size-6 items-center justify-center rounded text-xs font-medium"
|
||||||
|
>
|
||||||
|
{f.key}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 80,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Row actions
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }: { row: { original: MemberRow } }) => {
|
||||||
|
const m = row.original;
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="hover:bg-accent inline-flex size-8 items-center justify-center rounded-md"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
data-test="member-row-actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onPreview(m)}>
|
||||||
|
<Eye className="mr-2 size-4" />
|
||||||
|
Ansehen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onNavigate(`/home/${account}/members-cms/${m.id}/edit`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{m.email && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`mailto:${m.email}`, '_self');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Mail className="mr-2 size-4" />
|
||||||
|
E-Mail senden
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Archive className="mr-2 size-4" />
|
||||||
|
Archivieren
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Download, Filter, Plus, Search, X } from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@kit/ui/select';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
|
||||||
|
|
||||||
|
import {
|
||||||
|
exportMembers,
|
||||||
|
exportMembersExcel,
|
||||||
|
bulkUpdateStatus,
|
||||||
|
bulkArchiveMembers,
|
||||||
|
} from '../server/actions/member-actions';
|
||||||
|
import { MembersFilterPanel } from './members-filter-panel';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'Alle Status' },
|
||||||
|
{ value: 'active', label: 'Aktiv' },
|
||||||
|
{ value: 'inactive', label: 'Inaktiv' },
|
||||||
|
{ value: 'pending', label: 'Ausstehend' },
|
||||||
|
{ value: 'resigned', label: 'Ausgetreten' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface MembersToolbarProps {
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
searchValue: string;
|
||||||
|
statusFilter: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onStatusChange: (value: string) => void;
|
||||||
|
selectedCount: number;
|
||||||
|
selectedIds: string[];
|
||||||
|
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||||
|
duesCategories: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersToolbar({
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
searchValue,
|
||||||
|
statusFilter,
|
||||||
|
onSearchChange,
|
||||||
|
onStatusChange,
|
||||||
|
selectedCount,
|
||||||
|
selectedIds,
|
||||||
|
departments,
|
||||||
|
duesCategories,
|
||||||
|
}: MembersToolbarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [searchInput, setSearchInput] = useState(searchValue);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
|
const { execute: csvDownload, isPending: isCsvDownloading } =
|
||||||
|
useFileDownloadAction(exportMembers);
|
||||||
|
const { execute: excelDownload, isPending: isExcelDownloading } =
|
||||||
|
useFileDownloadAction(exportMembersExcel);
|
||||||
|
|
||||||
|
// Debounced search — auto-triggers after 300ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchInput === searchValue) return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
onSearchChange(searchInput);
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchInput, searchValue, onSearchChange]);
|
||||||
|
|
||||||
|
const { execute: executeBulkStatus } = useAction(bulkUpdateStatus, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Status aktualisiert');
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executeBulkArchive } = useAction(bulkArchiveMembers, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Mitglieder archiviert');
|
||||||
|
setArchiveDialogOpen(false);
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Archivieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect OS for shortcut hint
|
||||||
|
const isMac =
|
||||||
|
typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac');
|
||||||
|
const shortcutHint = isMac ? '⌘K' : 'Ctrl+K';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Main toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md min-w-[200px] flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="Suchen..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pr-16 pl-9"
|
||||||
|
data-test="members-search"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1">
|
||||||
|
{searchInput ? (
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchInput('');
|
||||||
|
onSearchChange('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<kbd className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||||
|
{shortcutHint}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => onStatusChange(v === 'all' ? '' : (v ?? ''))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-[150px]"
|
||||||
|
data-test="members-status-filter"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Alle Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value || 'all'} value={opt.value || 'all'}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Advanced filter */}
|
||||||
|
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1 rounded-lg border px-2.5 text-sm font-medium"
|
||||||
|
data-test="members-filter-btn"
|
||||||
|
>
|
||||||
|
<Filter className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Filter</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="start">
|
||||||
|
<MembersFilterPanel
|
||||||
|
departments={departments}
|
||||||
|
duesCategories={duesCategories}
|
||||||
|
onClose={() => setFilterOpen(false)}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Export — collapsed on mobile */}
|
||||||
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
csvDownload({
|
||||||
|
accountId,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
format: 'csv' as const,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isCsvDownloading}
|
||||||
|
data-test="members-export-csv"
|
||||||
|
>
|
||||||
|
<Download className="mr-1.5 size-4" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
excelDownload({
|
||||||
|
accountId,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
format: 'excel' as const,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isExcelDownloading}
|
||||||
|
data-test="members-export-excel"
|
||||||
|
>
|
||||||
|
<Download className="mr-1.5 size-4" />
|
||||||
|
Excel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create */}
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/members-cms/new`}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-sm font-medium transition-colors"
|
||||||
|
data-test="members-create-btn"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Neues Mitglied</span>
|
||||||
|
<span className="sm:hidden">Neu</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions bar */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="bg-muted/50 border-primary/20 flex items-center gap-3 rounded-lg border px-4 py-2">
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{selectedCount} ausgewählt
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
onValueChange={(status) =>
|
||||||
|
executeBulkStatus({
|
||||||
|
memberIds: selectedIds,
|
||||||
|
accountId,
|
||||||
|
status: status as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[160px]">
|
||||||
|
<SelectValue placeholder="Status ändern" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Aktiv setzen</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inaktiv setzen</SelectItem>
|
||||||
|
<SelectItem value="pending">Ausstehend setzen</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveDialogOpen(true)}
|
||||||
|
data-test="members-bulk-archive"
|
||||||
|
>
|
||||||
|
Archivieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archive confirmation dialog */}
|
||||||
|
<AlertDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mitglieder archivieren</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Möchten Sie {selectedCount} Mitglied
|
||||||
|
{selectedCount > 1 ? 'er' : ''} wirklich archivieren?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() =>
|
||||||
|
executeBulkArchive({ memberIds: selectedIds, accountId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Archivieren
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,3 +166,57 @@ export const AssignDepartmentSchema = z.object({
|
|||||||
memberId: z.string().uuid(),
|
memberId: z.string().uuid(),
|
||||||
departmentId: z.string().uuid(),
|
departmentId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Bulk operations & advanced search schemas ---
|
||||||
|
|
||||||
|
export const BulkStatusUpdateSchema = z.object({
|
||||||
|
memberIds: z.array(z.string().uuid()).min(1),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
status: MembershipStatusEnum,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BulkDepartmentAssignSchema = z.object({
|
||||||
|
memberIds: z.array(z.string().uuid()).min(1),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
departmentId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BulkArchiveSchema = z.object({
|
||||||
|
memberIds: z.array(z.string().uuid()).min(1),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MemberSearchFiltersSchema = z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
status: z.array(MembershipStatusEnum).optional(),
|
||||||
|
departmentIds: z.array(z.string().uuid()).optional(),
|
||||||
|
duesCategoryId: z.string().uuid().optional(),
|
||||||
|
flags: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
'honorary',
|
||||||
|
'founding',
|
||||||
|
'youth',
|
||||||
|
'retiree',
|
||||||
|
'probationary',
|
||||||
|
'transferred',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
entryDateFrom: z.string().optional(),
|
||||||
|
entryDateTo: z.string().optional(),
|
||||||
|
hasEmail: z.boolean().optional(),
|
||||||
|
sortBy: z.string().default('last_name'),
|
||||||
|
sortDirection: z.enum(['asc', 'desc']).default('asc'),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(25),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MemberSearchFilters = z.infer<typeof MemberSearchFiltersSchema>;
|
||||||
|
|
||||||
|
export const QuickSearchSchema = z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
query: z.string().min(1),
|
||||||
|
limit: z.number().int().min(1).max(20).default(8),
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
UpdateMandateSchema,
|
UpdateMandateSchema,
|
||||||
ExportMembersSchema,
|
ExportMembersSchema,
|
||||||
AssignDepartmentSchema,
|
AssignDepartmentSchema,
|
||||||
|
BulkStatusUpdateSchema,
|
||||||
|
BulkDepartmentAssignSchema,
|
||||||
|
BulkArchiveSchema,
|
||||||
|
QuickSearchSchema,
|
||||||
} from '../../schema/member.schema';
|
} from '../../schema/member.schema';
|
||||||
import { createMemberManagementApi } from '../api';
|
import { createMemberManagementApi } from '../api';
|
||||||
|
|
||||||
@@ -358,7 +362,7 @@ export const inviteMemberToPortal = authActionClient
|
|||||||
// In production: send invitation email with the token link
|
// In production: send invitation email with the token link
|
||||||
// For now: create the user directly via admin API
|
// For now: create the user directly via admin API
|
||||||
logger.info(
|
logger.info(
|
||||||
{ name: 'portal.invite', token: invitation.invite_token },
|
{ name: 'portal.invite', invitationId: invitation.id },
|
||||||
'Invitation created',
|
'Invitation created',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -373,3 +377,72 @@ export const revokePortalInvitation = authActionClient
|
|||||||
await api.revokePortalInvitation(input.invitationId);
|
await api.revokePortalInvitation(input.invitationId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Bulk operations ---
|
||||||
|
|
||||||
|
export const bulkUpdateStatus = authActionClient
|
||||||
|
.inputSchema(BulkStatusUpdateSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: 'member.bulkStatus', count: input.memberIds.length },
|
||||||
|
`Bulk updating status to ${input.status}...`,
|
||||||
|
);
|
||||||
|
await api.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bulkAssignDepartment = authActionClient
|
||||||
|
.inputSchema(BulkDepartmentAssignSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: 'member.bulkDepartment', count: input.memberIds.length },
|
||||||
|
'Bulk assigning department...',
|
||||||
|
);
|
||||||
|
await api.bulkAssignDepartment(input.memberIds, input.departmentId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bulkArchiveMembers = authActionClient
|
||||||
|
.inputSchema(BulkArchiveSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: 'member.bulkArchive', count: input.memberIds.length },
|
||||||
|
'Bulk archiving members...',
|
||||||
|
);
|
||||||
|
await api.bulkArchiveMembers(input.memberIds, ctx.user.id);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quickSearchMembers = authActionClient
|
||||||
|
.inputSchema(QuickSearchSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const results = await api.quickSearchMembers(
|
||||||
|
input.accountId,
|
||||||
|
input.query,
|
||||||
|
input.limit,
|
||||||
|
);
|
||||||
|
return { success: true, data: results };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getNextMemberNumber = authActionClient
|
||||||
|
.inputSchema(z.object({ accountId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const next = await api.getNextMemberNumber(input.accountId);
|
||||||
|
return { success: true, data: next };
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'server-only';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { todayISO } from '@kit/shared/dates';
|
import { todayISO } from '@kit/shared/dates';
|
||||||
@@ -5,6 +6,7 @@ import type { Database } from '@kit/supabase/database';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CreateMemberInput,
|
CreateMemberInput,
|
||||||
|
MemberSearchFilters,
|
||||||
UpdateMemberInput,
|
UpdateMemberInput,
|
||||||
} from '../schema/member.schema';
|
} from '../schema/member.schema';
|
||||||
|
|
||||||
@@ -49,11 +51,12 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
|||||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMember(memberId: string) {
|
async getMember(accountId: string, memberId: string) {
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('members')
|
.from('members')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', memberId)
|
.eq('id', memberId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
@@ -789,5 +792,231 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Quick stats for inline KPI bar ---
|
||||||
|
async getMemberQuickStats(accountId: string) {
|
||||||
|
const { data, error } = await (client.rpc as any)(
|
||||||
|
'get_member_quick_stats',
|
||||||
|
{
|
||||||
|
p_account_id: accountId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
const row = Array.isArray(data) ? data[0] : data;
|
||||||
|
return {
|
||||||
|
total: Number(row?.total ?? 0),
|
||||||
|
active: Number(row?.active ?? 0),
|
||||||
|
inactive: Number(row?.inactive ?? 0),
|
||||||
|
pending: Number(row?.pending ?? 0),
|
||||||
|
resigned: Number(row?.resigned ?? 0),
|
||||||
|
newThisYear: Number(row?.new_this_year ?? 0),
|
||||||
|
pendingApplications: Number(row?.pending_applications ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Advanced search with multi-filter ---
|
||||||
|
async searchMembers(filters: MemberSearchFilters) {
|
||||||
|
const {
|
||||||
|
accountId,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
departmentIds,
|
||||||
|
duesCategoryId,
|
||||||
|
flags,
|
||||||
|
entryDateFrom,
|
||||||
|
entryDateTo,
|
||||||
|
hasEmail,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
let query = client
|
||||||
|
.from('members')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('account_id', accountId);
|
||||||
|
|
||||||
|
// Multi-status filter
|
||||||
|
if (status && status.length > 0) {
|
||||||
|
query = query.in('status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text search
|
||||||
|
if (search) {
|
||||||
|
query = query.or(
|
||||||
|
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%,member_number.ilike.%${search}%,city.ilike.%${search}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dues category filter
|
||||||
|
if (duesCategoryId) {
|
||||||
|
query = query.eq('dues_category_id', duesCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag filters
|
||||||
|
if (flags && flags.length > 0) {
|
||||||
|
for (const flag of flags) {
|
||||||
|
const col = `is_${flag === 'founding' ? 'founding_member' : flag}`;
|
||||||
|
query = query.eq(col, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filters
|
||||||
|
if (entryDateFrom) {
|
||||||
|
query = query.gte('entry_date', entryDateFrom);
|
||||||
|
}
|
||||||
|
if (entryDateTo) {
|
||||||
|
query = query.lte('entry_date', entryDateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has email filter
|
||||||
|
if (hasEmail === true) {
|
||||||
|
query = query.not('email', 'is', null).neq('email', '');
|
||||||
|
} else if (hasEmail === false) {
|
||||||
|
query = query.or('email.is.null,email.eq.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department filter — requires subquery via member_department_assignments
|
||||||
|
if (departmentIds && departmentIds.length > 0) {
|
||||||
|
const { data: memberIds } = await client
|
||||||
|
.from('member_department_assignments')
|
||||||
|
.select('member_id')
|
||||||
|
.in('department_id', departmentIds);
|
||||||
|
const ids = (memberIds ?? []).map((d) => d.member_id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
query = query.in('id', ids);
|
||||||
|
} else {
|
||||||
|
// No members in selected departments
|
||||||
|
return { data: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const ascending = sortDirection === 'asc';
|
||||||
|
const sortColumn =
|
||||||
|
sortBy === 'first_name'
|
||||||
|
? 'first_name'
|
||||||
|
: sortBy === 'entry_date'
|
||||||
|
? 'entry_date'
|
||||||
|
: sortBy === 'member_number'
|
||||||
|
? 'member_number'
|
||||||
|
: sortBy === 'city'
|
||||||
|
? 'city'
|
||||||
|
: sortBy === 'status'
|
||||||
|
? 'status'
|
||||||
|
: 'last_name';
|
||||||
|
query = query.order(sortColumn, { ascending });
|
||||||
|
if (sortColumn !== 'first_name') {
|
||||||
|
query = query.order('first_name', { ascending: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Quick search for command palette ---
|
||||||
|
async quickSearchMembers(
|
||||||
|
accountId: string,
|
||||||
|
searchQuery: string,
|
||||||
|
limit = 8,
|
||||||
|
) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('members')
|
||||||
|
.select('id, first_name, last_name, email, member_number, status')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.or(
|
||||||
|
`last_name.ilike.%${searchQuery}%,first_name.ilike.%${searchQuery}%,email.ilike.%${searchQuery}%,member_number.ilike.%${searchQuery}%`,
|
||||||
|
)
|
||||||
|
.order('last_name')
|
||||||
|
.limit(limit);
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Next member number ---
|
||||||
|
async getNextMemberNumber(accountId: string) {
|
||||||
|
const { data, error } = await (client.rpc as any)(
|
||||||
|
'get_next_member_number',
|
||||||
|
{
|
||||||
|
p_account_id: accountId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
return String(data ?? '0001');
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Bulk operations ---
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
memberIds: string[],
|
||||||
|
status: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const { error } = await client
|
||||||
|
.from('members')
|
||||||
|
.update({
|
||||||
|
status: status as any,
|
||||||
|
updated_by: userId,
|
||||||
|
...(status === 'resigned' ? { exit_date: todayISO() } : {}),
|
||||||
|
})
|
||||||
|
.in('id', memberIds);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkAssignDepartment(memberIds: string[], departmentId: string) {
|
||||||
|
const rows = memberIds.map((memberId) => ({
|
||||||
|
member_id: memberId,
|
||||||
|
department_id: departmentId,
|
||||||
|
}));
|
||||||
|
const { error } = await client
|
||||||
|
.from('member_department_assignments')
|
||||||
|
.upsert(rows, { onConflict: 'member_id,department_id' });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkArchiveMembers(memberIds: string[], userId: string) {
|
||||||
|
const { error } = await client
|
||||||
|
.from('members')
|
||||||
|
.update({
|
||||||
|
is_archived: true,
|
||||||
|
updated_by: userId,
|
||||||
|
})
|
||||||
|
.in('id', memberIds);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Departments with member counts ---
|
||||||
|
async listDepartmentsWithCounts(accountId: string) {
|
||||||
|
const { data: departments, error: deptError } = await client
|
||||||
|
.from('member_departments')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('sort_order');
|
||||||
|
if (deptError) throw deptError;
|
||||||
|
|
||||||
|
const deptIds = (departments ?? []).map((d) => d.id);
|
||||||
|
if (deptIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { data: assignments, error: assignError } = await client
|
||||||
|
.from('member_department_assignments')
|
||||||
|
.select('department_id')
|
||||||
|
.in('department_id', deptIds);
|
||||||
|
if (assignError) throw assignError;
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const a of assignments ?? []) {
|
||||||
|
counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (departments ?? []).map((d) => ({
|
||||||
|
...d,
|
||||||
|
memberCount: counts.get(d.id) ?? 0,
|
||||||
|
}));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ interface Props {
|
|||||||
initialData: Record<string, unknown>;
|
initialData: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteEditor({ pageId, accountId: _accountId, initialData }: Props) {
|
export function SiteEditor({
|
||||||
|
pageId,
|
||||||
|
accountId: _accountId,
|
||||||
|
initialData,
|
||||||
|
}: Props) {
|
||||||
const { execute: execPublish } = useActionWithToast(publishPage, {
|
const { execute: execPublish } = useActionWithToast(publishPage, {
|
||||||
successMessage: 'Seite veröffentlicht',
|
successMessage: 'Seite veröffentlicht',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6404,6 +6404,22 @@ export type Database = {
|
|||||||
total_upcoming_events: number
|
total_upcoming_events: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
get_member_quick_stats: {
|
||||||
|
Args: { p_account_id: string }
|
||||||
|
Returns: {
|
||||||
|
active: number
|
||||||
|
inactive: number
|
||||||
|
new_this_year: number
|
||||||
|
pending: number
|
||||||
|
pending_applications: number
|
||||||
|
resigned: number
|
||||||
|
total: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
get_next_member_number: {
|
||||||
|
Args: { p_account_id: string }
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
get_nonce_status: { Args: { p_id: string }; Returns: Json }
|
get_nonce_status: { Args: { p_id: string }; Returns: Json }
|
||||||
get_upper_system_role: { Args: never; Returns: string }
|
get_upper_system_role: { Args: never; Returns: string }
|
||||||
get_user_visible_accounts: { Args: never; Returns: string[] }
|
get_user_visible_accounts: { Args: never; Returns: string[] }
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1404,6 +1404,9 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.101.0
|
version: 2.101.0
|
||||||
|
'@tanstack/react-table':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@types/papaparse':
|
'@types/papaparse':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.5.2
|
version: 5.5.2
|
||||||
|
|||||||
Reference in New Issue
Block a user