@@ -521,19 +523,19 @@ export function PricingCalculator() {
{USP_FEATURES.map((f, i) => (
{f.label}
- {(
- ['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
- ).map((col) => (
-
-
-
- ))}
+ {(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
+ (col) => (
+
+
+
+ ),
+ )}
))}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
index 138c09500..a550f975b 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
@@ -23,7 +23,7 @@ export default async function EditMemberPage({ params }: Props) {
if (!acct) return
;
const api = createMemberManagementApi(client);
- const member = await api.getMember(memberId);
+ const member = await api.getMember(acct.id, memberId);
if (!member) return
{t('detail.notFound')}
;
return (
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
index 1ad966883..fd5907584 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
@@ -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
;
const api = createMemberManagementApi(client);
- const member = await api.getMember(memberId);
+ const member = await api.getMember(acct.id, memberId);
if (!member) return
;
- // 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 (
-
-
-
+ accountId={acct.id}
+ roles={roles}
+ honors={honors}
+ mandates={mandates}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx
new file mode 100644
index 000000000..fcc09ebc1
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx
@@ -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}
+
+
+
+ {/* Stats bar — only on main views */}
+ {!isOnSubPage &&
}
+
+ {/* Tab navigation + settings */}
+ {!isOnSubPage && (
+
+
+
+
+
+ )}
+
+ {children}
+
+
+
+
+ >
+ );
+}
+
+function TabLink({
+ href,
+ active,
+ children,
+}: {
+ href: string;
+ active: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SettingsMenu({ basePath }: { basePath: string }) {
+ const router = useRouter();
+
+ const navigate = (path: string) => () => router.push(path);
+
+ return (
+
+
+
+
+
+
+
+ Beitragskategorien
+
+
+
+ Abteilungen
+
+
+
+ Mitgliedsausweise
+
+
+
+ Portal-Einladungen
+
+
+
+ Import
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
index f30ac0b3b..c9410e762 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
@@ -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 (
-
-
-
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
new file mode 100644
index 000000000..7addf84b2
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
@@ -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
;
+
+ const api = createMemberManagementApi(client);
+ const stats = await api.getMemberQuickStats(acct.id);
+
+ return (
+
+ }
+ account={account}
+ accountId={acct.id}
+ stats={stats}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
index 4c7f49fb9..b0fcef446 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
@@ -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 (
-
- ) => ({
- id: String(c.id),
- name: String(c.name),
- amount: Number(c.amount ?? 0),
- }),
- )}
- />
-
+ duesCategories={(duesCategories ?? []).map(
+ (c: Record
) => ({
+ id: String(c.id),
+ name: String(c.name),
+ amount: Number(c.amount ?? 0),
+ }),
+ )}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
index c8951f7e6..65fe3051d 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
@@ -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 (
-
- ) => ({
- id: String(c.id),
- name: String(c.name),
- }),
- )}
- />
-
+ accountId={acct.id}
+ duesCategories={(duesCategories ?? []).map(
+ (c: Record) => ({
+ id: String(c.id),
+ name: String(c.name),
+ }),
+ )}
+ departments={(departments ?? []).map((d) => ({
+ id: String(d.id),
+ name: String(d.name),
+ memberCount: d.memberCount,
+ }))}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
index 590488988..4e5486d70 100644
--- a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
@@ -1,4 +1,3 @@
-
import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx
index 8ffe909bf..f10b75835 100644
--- a/apps/web/config/team-account-navigation.config.tsx
+++ b/apps/web/config/team-account-navigation.config.tsx
@@ -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) => {
+const getRoutes = (
+ account: string,
+ accountFeatures?: Record,
+) => {
const routes: Array<
| {
label: string;
@@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}> = [];
if (featureFlagsConfig.enableMemberManagement) {
- peopleChildren.push(
- {
- label: 'common.routes.clubMembers',
- path: createPath(pathsConfig.app.accountCmsMembers, account),
- Icon: ,
- },
- {
- label: 'common.routes.memberApplications',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/applications',
- account,
- ),
- Icon: ,
- },
- // 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: ,
- // },
- {
- label: 'common.routes.memberCards',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/cards',
- account,
- ),
- Icon: ,
- },
- {
- label: 'common.routes.memberDues',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/dues',
- account,
- ),
- Icon: ,
- },
- );
+ peopleChildren.push({
+ label: 'common.routes.clubMembers',
+ path: createPath(pathsConfig.app.accountCmsMembers, account),
+ Icon: ,
+ });
}
// Admin users who can log in — always visible
@@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}
// ── 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) =
}
// ── 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) =
}
// ── Association Management (Verband) ──
- if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) {
+ if (
+ featureFlagsConfig.enableVerbandsverwaltung &&
+ accountFeatures?.verband !== false
+ ) {
routes.push({
label: 'common.routes.associationManagement',
collapsible: true,
diff --git a/apps/web/lib/database.types.ts b/apps/web/lib/database.types.ts
index 8a49b1cc1..1307ff791 100644
--- a/apps/web/lib/database.types.ts
+++ b/apps/web/lib/database.types.ts
@@ -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[] }
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index bdb04e3f9..2b101d068 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -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,
diff --git a/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql
new file mode 100644
index 000000000..dc802c1a4
--- /dev/null
+++ b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql
@@ -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;
diff --git a/apps/web/supabase/seed.sql b/apps/web/supabase/seed.sql
index 58eb6aa13..f94634f61 100644
--- a/apps/web/supabase/seed.sql
+++ b/apps/web/supabase/seed.sql
@@ -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 $$;
diff --git a/packages/features/member-management/package.json b/packages/features/member-management/package.json
index b7ba90244..c3975d1b5 100644
--- a/packages/features/member-management/package.json
+++ b/packages/features/member-management/package.json
@@ -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:",
diff --git a/packages/features/member-management/src/components/index.ts b/packages/features/member-management/src/components/index.ts
index 176706564..6815f98f2 100644
--- a/packages/features/member-management/src/components/index.ts
+++ b/packages/features/member-management/src/components/index.ts
@@ -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';
diff --git a/packages/features/member-management/src/components/member-avatar.tsx b/packages/features/member-management/src/components/member-avatar.tsx
new file mode 100644
index 000000000..4296a5d7a
--- /dev/null
+++ b/packages/features/member-management/src/components/member-avatar.tsx
@@ -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 (
+
+
+ {initials}
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-command-palette.tsx b/packages/features/member-management/src/components/member-command-palette.tsx
new file mode 100644
index 000000000..598a28b04
--- /dev/null
+++ b/packages/features/member-management/src/components/member-command-palette.tsx
@@ -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 (
+
+
+
+ Keine Mitglieder gefunden.
+
+ {results.length > 0 && (
+
+ {results.map((m) => (
+ handleSelect(m.id)}
+ className="flex items-center gap-3"
+ >
+
+
+
+ {m.first_name} {m.last_name}
+
+ {m.member_number && (
+
+ Nr. {m.member_number}
+
+ )}
+
+
+ {STATUS_LABELS[m.status] ?? m.status}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/new`);
+ }}
+ >
+
+ Neues Mitglied erstellen
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/import`);
+ }}
+ >
+
+ Import starten
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/applications`);
+ }}
+ >
+
+ Aufnahmeanträge anzeigen
+
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-create-wizard.tsx b/packages/features/member-management/src/components/member-create-wizard.tsx
new file mode 100644
index 000000000..0d62fcfc3
--- /dev/null
+++ b/packages/features/member-management/src/components/member-create-wizard.tsx
@@ -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([]);
+
+ 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 (
+
+ {/* Step indicator */}
+