From f43770999fd69d74d40a53c62a1e75b6431bb867 Mon Sep 17 00:00:00 2001 From: "T. Zehetbauer" <125989630+4thTomost@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:56:04 +0200 Subject: [PATCH] feat: enhance member management features; add quick stats and search capabilities --- apps/e2e/tests/member-lifecycle.spec.ts | 4 +- .../_components/feature-carousel.tsx | 5 +- .../_components/pricing-calculator.tsx | 50 +- .../members-cms/[memberId]/edit/page.tsx | 2 +- .../[account]/members-cms/[memberId]/page.tsx | 25 +- .../_components/members-cms-layout-client.tsx | 178 ++++ .../members-cms/applications/page.tsx | 19 +- .../home/[account]/members-cms/layout.tsx | 47 + .../home/[account]/members-cms/new/page.tsx | 32 +- .../home/[account]/members-cms/page.tsx | 67 +- .../[account]/newsletter/templates/page.tsx | 1 - .../config/team-account-navigation.config.tsx | 68 +- apps/web/lib/database.types.ts | 16 + apps/web/next.config.mjs | 1 + ...20260415000001_member_search_and_stats.sql | 100 ++ apps/web/supabase/seed.sql | 143 +++ .../features/member-management/package.json | 1 + .../member-management/src/components/index.ts | 8 + .../src/components/member-avatar.tsx | 54 ++ .../src/components/member-command-palette.tsx | 165 ++++ .../src/components/member-create-wizard.tsx | 770 +++++++++++++++ .../src/components/member-detail-header.tsx | 218 +++++ .../src/components/member-detail-tabs.tsx | 899 ++++++++++++++++++ .../src/components/member-quick-preview.tsx | 205 ++++ .../src/components/member-stats-bar.tsx | 59 ++ .../src/components/members-filter-panel.tsx | 175 ++++ .../src/components/members-list-view.tsx | 265 ++++++ .../src/components/members-table-columns.tsx | 263 +++++ .../src/components/members-toolbar.tsx | 304 ++++++ .../src/schema/member.schema.ts | 54 ++ .../src/server/actions/member-actions.ts | 75 +- .../member-management/src/server/api.ts | 231 ++++- .../src/components/site-editor.tsx | 6 +- packages/supabase/src/database.types.ts | 16 + pnpm-lock.yaml | 3 + 35 files changed, 4370 insertions(+), 159 deletions(-) create mode 100644 apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx create mode 100644 apps/web/app/[locale]/home/[account]/members-cms/layout.tsx create mode 100644 apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql create mode 100644 packages/features/member-management/src/components/member-avatar.tsx create mode 100644 packages/features/member-management/src/components/member-command-palette.tsx create mode 100644 packages/features/member-management/src/components/member-create-wizard.tsx create mode 100644 packages/features/member-management/src/components/member-detail-header.tsx create mode 100644 packages/features/member-management/src/components/member-detail-tabs.tsx create mode 100644 packages/features/member-management/src/components/member-quick-preview.tsx create mode 100644 packages/features/member-management/src/components/member-stats-bar.tsx create mode 100644 packages/features/member-management/src/components/members-filter-panel.tsx create mode 100644 packages/features/member-management/src/components/members-list-view.tsx create mode 100644 packages/features/member-management/src/components/members-table-columns.tsx create mode 100644 packages/features/member-management/src/components/members-toolbar.tsx diff --git a/apps/e2e/tests/member-lifecycle.spec.ts b/apps/e2e/tests/member-lifecycle.spec.ts index 17cfe00a1..b329b7000 100644 --- a/apps/e2e/tests/member-lifecycle.spec.ts +++ b/apps/e2e/tests/member-lifecycle.spec.ts @@ -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'); diff --git a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx index ddfe84afc..0bf69eaef 100644 --- a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx @@ -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), [], diff --git a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx index 2b3045dfa..8ba82c27d 100644 --- a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx +++ b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx @@ -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({
{available ? (
0 ? 4 : 0, @@ -323,7 +323,10 @@ export function PricingCalculator() {
{/* ── Header ── */}
- + Preisvergleich

@@ -376,7 +379,7 @@ export function PricingCalculator() {
-
+
Ihr MYeasyCMS-Tarif
@@ -460,21 +463,20 @@ export function PricingCalculator() { {bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
-
+
Ersparnis vs. {bestSaving.name.split(' ')[0]}
{fmt((bestSaving.p - tier.price) * 12)} €
- pro Jahr ( - {Math.round((1 - tier.price / bestSaving.p) * 100)}% + pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}% günstiger)
-
+
Preis pro Mitglied
@@ -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 */} + + +
+ + {/* Step 1: Basisdaten */} + {step === 1 && ( + + + Basisdaten + + +
+ ( + + Vorname * + + + + + + )} + /> + ( + + Nachname * + + + + + + )} + /> +
+ + ( + + E-Mail + + + + + + )} + /> + +
+ ( + + Mitgliedsnr. + + + + + + )} + /> + ( + + Status + + + + )} + /> +
+ + ( + + Eintrittsdatum + + + + + + )} + /> +
+
+ )} + + {/* Step 2: Weitere Angaben */} + {step === 2 && ( +
+ + + Kontakt + + +
+ ( + + Telefon + + + + + + )} + /> + ( + + Mobil + + + + + + )} + /> +
+
+
+ + + + Adresse + + +
+ ( + + Straße + + + + + + )} + /> + ( + + Hausnr. + + + + + + )} + /> +
+
+ ( + + PLZ + + + + + + )} + /> + ( + + Ort + + + + + + )} + /> +
+
+
+ + + + Persönliche Daten + + +
+ ( + + Geburtsdatum + + + + + + )} + /> + ( + + Geschlecht + + + + )} + /> +
+
+ ( + + Anrede + + + + + + )} + /> + ( + + Titel + + + + + + )} + /> +
+
+
+
+ )} + + {/* Step 3: Mitgliedschaft & Finanzen */} + {step === 3 && ( +
+ + + Beitrag + + + {duesCategories.length > 0 && ( + ( + + Beitragskategorie + + + + )} + /> + )} + + + + + + Bankverbindung + + +
+ ( + + IBAN + + + + + + )} + /> + ( + + BIC + + + + + + )} + /> +
+ ( + + Kontoinhaber + + + + + + )} + /> +
+
+ + + + Merkmale + + +
+ {[ + { 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 }) => ( + ( + + + + + + {label} + + + )} + /> + ))} +
+
+
+ + + + DSGVO-Einwilligungen + + +
+ {[ + { + 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 }) => ( + ( + + + + + + {label} + + + )} + /> + ))} +
+
+
+ + + + Notizen + + + ( + + +