feat: enhance member management features; add quick stats and search capabilities

This commit is contained in:
T. Zehetbauer
2026-04-02 22:56:04 +02:00
parent 0932c57fa1
commit f43770999f
35 changed files with 4370 additions and 159 deletions

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test';
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.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'testpassword123');

View File

@@ -457,10 +457,7 @@ export function FeatureCarousel() {
const [active, setActive] = useState(0);
const slide = SLIDES[active]!;
const next = useCallback(
() => setActive((i) => (i + 1) % SLIDES.length),
[],
);
const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
const prev = useCallback(
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
[],

View File

@@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
} from '@kit/ui/card';
import { Card, CardContent } from '@kit/ui/card';
import {
Table,
TableBody,
@@ -265,7 +262,10 @@ function PriceBar({
<div className="bg-muted h-6 overflow-hidden rounded-md">
{available ? (
<div
className={cn('h-full rounded-md transition-all duration-500', color)}
className={cn(
'h-full rounded-md transition-all duration-500',
color,
)}
style={{
width: `${pct}%`,
minWidth: pct > 0 ? 4 : 0,
@@ -323,7 +323,10 @@ export function PricingCalculator() {
<div className="mx-auto w-full max-w-4xl space-y-0">
{/* ── Header ── */}
<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
</Badge>
<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">
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
<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
</div>
<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 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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]}
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
{fmt((bestSaving.p - tier.price) * 12)}
</div>
<div className="text-muted-foreground text-sm">
pro Jahr (
{Math.round((1 - tier.price / bestSaving.p) * 100)}%
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
günstiger)
</div>
</Card>
<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
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
@@ -521,19 +523,19 @@ export function PricingCalculator() {
{USP_FEATURES.map((f, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{f.label}</TableCell>
{(
['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
).map((col) => (
<TableCell
key={col}
className={cn(
'text-center',
col === 'mcms' && 'bg-primary/5',
)}
>
<FeatureCell value={f[col]} />
</TableCell>
))}
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
(col) => (
<TableCell
key={col}
className={cn(
'text-center',
col === 'mcms' && 'bg-primary/5',
)}
>
<FeatureCell value={f[col]} />
</TableCell>
),
)}
</TableRow>
))}
</TableBody>

View File

@@ -23,7 +23,7 @@ export default async function EditMemberPage({ params }: Props) {
if (!acct) return <AccountNotFound />;
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>;
return (

View File

@@ -1,9 +1,8 @@
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; memberId: string }>;
@@ -20,10 +19,9 @@ export default async function MemberDetailPage({ params }: Props) {
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const member = await api.getMember(memberId);
const member = await api.getMember(acct.id, memberId);
if (!member) return <AccountNotFound />;
// Fetch sub-entities in parallel
const [roles, honors, mandates] = await Promise.all([
api.listMemberRoles(memberId),
api.listMemberHonors(memberId),
@@ -31,18 +29,13 @@ export default async function MemberDetailPage({ params }: Props) {
]);
return (
<CmsPageShell
<MemberDetailTabs
member={member}
account={account}
title={`${String(member.first_name)} ${String(member.last_name)}`}
>
<MemberDetailView
member={member}
account={account}
accountId={acct.id}
roles={roles}
honors={honors}
mandates={mandates}
/>
</CmsPageShell>
accountId={acct.id}
roles={roles}
honors={honors}
mandates={mandates}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -1,11 +1,8 @@
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { ApplicationWorkflow } from '@kit/member-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -14,7 +11,7 @@ interface Props {
export default async function ApplicationsPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('members');
const { data: acct } = await client
.from('accounts')
.select('id')
@@ -26,16 +23,10 @@ export default async function ApplicationsPage({ params }: Props) {
const applications = await api.listApplications(acct.id);
return (
<CmsPageShell
<ApplicationWorkflow
applications={applications}
accountId={acct.id}
account={account}
title={t('nav.applications')}
description={t('applications.subtitle')}
>
<ApplicationWorkflow
applications={applications}
accountId={acct.id}
account={account}
/>
</CmsPageShell>
/>
);
}

View 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>
);
}

View File

@@ -1,11 +1,8 @@
import { getTranslations } from 'next-intl/server';
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -13,7 +10,6 @@ interface Props {
export default async function NewMemberPage({ params }: Props) {
const { account } = await params;
const t = await getTranslations('members');
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
@@ -26,22 +22,16 @@ export default async function NewMemberPage({ params }: Props) {
const duesCategories = await api.listDuesCategories(acct.id);
return (
<CmsPageShell
<MemberCreateWizard
accountId={acct.id}
account={account}
title={t('form.newMemberTitle')}
description={t('form.newMemberDescription')}
>
<CreateMemberForm
accountId={acct.id}
account={account}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
amount: Number(c.amount ?? 0),
}),
)}
/>
</CmsPageShell>
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
amount: Number(c.amount ?? 0),
}),
)}
/>
);
}

View File

@@ -1,11 +1,8 @@
import { getTranslations } from 'next-intl/server';
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
const PAGE_SIZE = 25;
@@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('members');
const { data: acct } = await client
.from('accounts')
.select('id')
@@ -28,34 +25,50 @@ export default async function MembersPage({ params, searchParams }: Props) {
const api = createMemberManagementApi(client);
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,
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,
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 (
<CmsPageShell
<MembersListView
data={result.data}
total={result.total}
page={page}
pageSize={PAGE_SIZE}
account={account}
title={t('nav.members')}
description={`${result.total} ${t('nav.members')}`}
>
<MembersDataTable
data={result.data}
total={result.total}
page={page}
pageSize={PAGE_SIZE}
account={account}
accountId={acct.id}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
}),
)}
/>
</CmsPageShell>
accountId={acct.id}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
}),
)}
departments={(departments ?? []).map((d) => ({
id: String(d.id),
name: String(d.name),
memberCount: d.memberCount,
}))}
/>
);
}

View File

@@ -1,4 +1,3 @@
import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';

View File

@@ -5,9 +5,6 @@ import {
CreditCard,
// People (Members + Access)
UserCheck,
UserPlus,
IdCard,
ClipboardList,
// Courses
GraduationCap,
CalendarDays,
@@ -69,7 +66,10 @@ import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4';
const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) => {
const getRoutes = (
account: string,
accountFeatures?: Record<string, boolean>,
) => {
const routes: Array<
| {
label: string;
@@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
}> = [];
if (featureFlagsConfig.enableMemberManagement) {
peopleChildren.push(
{
label: 'common.routes.clubMembers',
path: createPath(pathsConfig.app.accountCmsMembers, account),
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} />,
},
);
peopleChildren.push({
label: 'common.routes.clubMembers',
path: createPath(pathsConfig.app.accountCmsMembers, account),
Icon: <UserCheck className={iconClasses} />,
});
}
// Admin users who can log in — always visible
@@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
}
// ── Fisheries ──
if (featureFlagsConfig.enableFischerei && (accountFeatures?.fischerei !== false)) {
if (
featureFlagsConfig.enableFischerei &&
accountFeatures?.fischerei !== false
) {
routes.push({
label: 'common.routes.fisheriesManagement',
collapsible: true,
@@ -473,7 +441,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
}
// ── Meeting Protocols ──
if (featureFlagsConfig.enableMeetingProtocols && (accountFeatures?.meetings !== false)) {
if (
featureFlagsConfig.enableMeetingProtocols &&
accountFeatures?.meetings !== false
) {
routes.push({
label: 'common.routes.meetingProtocols',
collapsible: true,
@@ -502,7 +473,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
}
// ── Association Management (Verband) ──
if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) {
if (
featureFlagsConfig.enableVerbandsverwaltung &&
accountFeatures?.verband !== false
) {
routes.push({
label: 'common.routes.associationManagement',
collapsible: true,

View File

@@ -6404,6 +6404,22 @@ export type Database = {
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_upper_system_role: { Args: never; Returns: string }
get_user_visible_accounts: { Args: never; Returns: string[] }

View File

@@ -41,6 +41,7 @@ const INTERNAL_PACKAGES = [
/** @type {import('next').NextConfig} */
const config = {
output: 'standalone',
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: INTERNAL_PACKAGES,

View File

@@ -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;

View File

@@ -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);
-- ══════════════════════════════════════════════════════════════
-- 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 $$;

View File

@@ -28,6 +28,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-table": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",

View File

@@ -6,3 +6,11 @@ export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
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';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 &quot;Ausgetreten&quot; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
},
];
}

View File

@@ -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>
);
}

View File

@@ -166,3 +166,57 @@ export const AssignDepartmentSchema = z.object({
memberId: 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),
});

View File

@@ -19,6 +19,10 @@ import {
UpdateMandateSchema,
ExportMembersSchema,
AssignDepartmentSchema,
BulkStatusUpdateSchema,
BulkDepartmentAssignSchema,
BulkArchiveSchema,
QuickSearchSchema,
} from '../../schema/member.schema';
import { createMemberManagementApi } from '../api';
@@ -358,7 +362,7 @@ export const inviteMemberToPortal = authActionClient
// In production: send invitation email with the token link
// For now: create the user directly via admin API
logger.info(
{ name: 'portal.invite', token: invitation.invite_token },
{ name: 'portal.invite', invitationId: invitation.id },
'Invitation created',
);
@@ -373,3 +377,72 @@ export const revokePortalInvitation = authActionClient
await api.revokePortalInvitation(input.invitationId);
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 };
});

View File

@@ -1,3 +1,4 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { todayISO } from '@kit/shared/dates';
@@ -5,6 +6,7 @@ import type { Database } from '@kit/supabase/database';
import type {
CreateMemberInput,
MemberSearchFilters,
UpdateMemberInput,
} from '../schema/member.schema';
@@ -49,11 +51,12 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getMember(memberId: string) {
async getMember(accountId: string, memberId: string) {
const { data, error } = await client
.from('members')
.select('*')
.eq('id', memberId)
.eq('account_id', accountId)
.single();
if (error) throw error;
return data;
@@ -789,5 +792,231 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (error) throw error;
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,
}));
},
};
}

View File

@@ -14,7 +14,11 @@ interface Props {
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, {
successMessage: 'Seite veröffentlicht',
});

View File

@@ -6404,6 +6404,22 @@ export type Database = {
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_upper_system_role: { Args: never; Returns: string }
get_user_visible_accounts: { Args: never; Returns: string[] }

3
pnpm-lock.yaml generated
View File

@@ -1404,6 +1404,9 @@ importers:
'@supabase/supabase-js':
specifier: 'catalog:'
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':
specifier: 'catalog:'
version: 5.5.2