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

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

View File

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

View File

@@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
} from '@kit/ui/card';
import { Card, CardContent } from '@kit/ui/card';
import {
Table,
TableBody,
@@ -265,7 +262,10 @@ function PriceBar({
<div className="bg-muted h-6 overflow-hidden rounded-md">
{available ? (
<div
className={cn('h-full rounded-md transition-all duration-500', color)}
className={cn(
'h-full rounded-md transition-all duration-500',
color,
)}
style={{
width: `${pct}%`,
minWidth: pct > 0 ? 4 : 0,
@@ -323,7 +323,10 @@ export function PricingCalculator() {
<div className="mx-auto w-full max-w-4xl space-y-0">
{/* ── Header ── */}
<div className="bg-primary rounded-t-2xl px-8 py-7">
<Badge variant="outline" className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] uppercase tracking-widest">
<Badge
variant="outline"
className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] tracking-widest uppercase"
>
Preisvergleich
</Badge>
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
@@ -376,7 +379,7 @@ export function PricingCalculator() {
<div className="flex flex-wrap items-center gap-3">
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
<div>
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
Ihr MYeasyCMS-Tarif
</div>
<div className="font-heading text-primary text-xl font-bold">
@@ -460,21 +463,20 @@ export function PricingCalculator() {
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
Ersparnis vs. {bestSaving.name.split(' ')[0]}
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
{fmt((bestSaving.p - tier.price) * 12)}
</div>
<div className="text-muted-foreground text-sm">
pro Jahr (
{Math.round((1 - tier.price / bestSaving.p) * 100)}%
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
günstiger)
</div>
</Card>
<Card className="bg-muted/50 p-5 text-center">
<div className="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">
<div className="text-muted-foreground text-[10px] font-bold tracking-wider uppercase">
Preis pro Mitglied
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
@@ -521,19 +523,19 @@ export function PricingCalculator() {
{USP_FEATURES.map((f, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{f.label}</TableCell>
{(
['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
).map((col) => (
<TableCell
key={col}
className={cn(
'text-center',
col === 'mcms' && 'bg-primary/5',
)}
>
<FeatureCell value={f[col]} />
</TableCell>
))}
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
(col) => (
<TableCell
key={col}
className={cn(
'text-center',
col === 'mcms' && 'bg-primary/5',
)}
>
<FeatureCell value={f[col]} />
</TableCell>
),
)}
</TableRow>
))}
</TableBody>

View File

@@ -23,7 +23,7 @@ export default async function EditMemberPage({ params }: Props) {
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const member = await api.getMember(memberId);
const member = await api.getMember(acct.id, memberId);
if (!member) return <div>{t('detail.notFound')}</div>;
return (

View File

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

View File

@@ -0,0 +1,178 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
FileDown,
FileUp,
IdCard,
KeyRound,
LayoutList,
Settings,
Users,
} from 'lucide-react';
import {
MemberStatsBar,
MemberCommandPalette,
} from '@kit/member-management/components';
import { Badge } from '@kit/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { PageBody } from '@kit/ui/page';
import { cn } from '@kit/ui/utils';
interface MembersCmsLayoutClientProps {
header: ReactNode;
children: ReactNode;
account: string;
accountId: string;
stats: {
total: number;
active: number;
pending: number;
newThisYear: number;
pendingApplications: number;
};
}
export function MembersCmsLayoutClient({
header,
children,
account,
accountId,
stats,
}: MembersCmsLayoutClientProps) {
const pathname = usePathname();
const basePath = `/home/${account}/members-cms`;
const isOnMembersTab =
pathname.endsWith('/members-cms') ||
pathname.includes('/members-cms/new') ||
/\/members-cms\/[^/]+$/.test(pathname);
const isOnApplicationsTab = pathname.includes('/applications');
const isOnSubPage =
pathname.includes('/import') ||
pathname.includes('/edit') ||
(/\/members-cms\/[^/]+$/.test(pathname) &&
!pathname.endsWith('/members-cms'));
return (
<>
{header}
<PageBody>
<div className="space-y-4">
{/* Stats bar — only on main views */}
{!isOnSubPage && <MemberStatsBar stats={stats} />}
{/* Tab navigation + settings */}
{!isOnSubPage && (
<div className="flex items-center justify-between border-b">
<nav className="-mb-px flex gap-4">
<TabLink
href={basePath}
active={isOnMembersTab && !isOnApplicationsTab}
>
<Users className="size-4" />
Mitglieder
</TabLink>
<TabLink
href={`${basePath}/applications`}
active={isOnApplicationsTab}
>
<FileUp className="size-4" />
Aufnahmeanträge
{stats.pendingApplications > 0 && (
<Badge
variant="destructive"
className="ml-1 h-5 min-w-5 px-1 text-xs"
>
{stats.pendingApplications}
</Badge>
)}
</TabLink>
</nav>
<SettingsMenu basePath={basePath} />
</div>
)}
{children}
</div>
<MemberCommandPalette account={account} accountId={accountId} />
</PageBody>
</>
);
}
function TabLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: ReactNode;
}) {
return (
<Link
href={href}
className={cn(
'inline-flex items-center gap-1.5 border-b-2 px-1 pb-2 text-sm font-medium transition-colors',
active
? 'border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground border-transparent',
)}
>
{children}
</Link>
);
}
function SettingsMenu({ basePath }: { basePath: string }) {
const router = useRouter();
const navigate = (path: string) => () => router.push(path);
return (
<DropdownMenu>
<DropdownMenuTrigger
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded-lg"
data-test="members-settings-menu"
>
<Settings className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={navigate(`${basePath}/dues`)}>
<LayoutList className="mr-2 size-4" />
Beitragskategorien
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/departments`)}>
<Users className="mr-2 size-4" />
Abteilungen
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
<IdCard className="mr-2 size-4" />
Mitgliedsausweise
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/invitations`)}>
<KeyRound className="mr-2 size-4" />
Portal-Einladungen
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/import`)}>
<FileDown className="mr-2 size-4" />
Import
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

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

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { createMemberManagementApi } from '@kit/member-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
import { MembersCmsLayoutClient } from './_components/members-cms-layout-client';
interface Props {
children: ReactNode;
params: Promise<{ account: string }>;
}
export default async function MembersCmsLayout({ children, params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const stats = await api.getMemberQuickStats(acct.id);
return (
<MembersCmsLayoutClient
header={
<TeamAccountLayoutPageHeader
account={account}
title="Mitglieder"
description={`${stats.total} Mitglieder verwalten`}
/>
}
account={account}
accountId={acct.id}
stats={stats}
>
{children}
</MembersCmsLayoutClient>
);
}

View File

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

View File

@@ -1,11 +1,8 @@
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { MembersDataTable } from '@kit/member-management/components';
import { MembersListView } from '@kit/member-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
const PAGE_SIZE = 25;
@@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('members');
const { data: acct } = await client
.from('accounts')
.select('id')
@@ -28,34 +25,50 @@ export default async function MembersPage({ params, searchParams }: Props) {
const api = createMemberManagementApi(client);
const page = Number(search.page) || 1;
const result = await api.listMembers(acct.id, {
// Parse multi-status filter
const statusParam = search.status;
const statusFilter = statusParam
? Array.isArray(statusParam)
? statusParam
: statusParam.split(',')
: undefined;
const result = await api.searchMembers({
accountId: acct.id,
search: search.q as string,
status: search.status as string,
status: statusFilter as any,
duesCategoryId: search.duesCategoryId as string,
sortBy: (search.sortBy as string) ?? 'last_name',
sortDirection: (search.sortDirection as 'asc' | 'desc') ?? 'asc',
page,
pageSize: PAGE_SIZE,
});
const duesCategories = await api.listDuesCategories(acct.id);
const [duesCategories, departments] = await Promise.all([
api.listDuesCategories(acct.id),
api.listDepartmentsWithCounts(acct.id),
]);
return (
<CmsPageShell
<MembersListView
data={result.data}
total={result.total}
page={page}
pageSize={PAGE_SIZE}
account={account}
title={t('nav.members')}
description={`${result.total} ${t('nav.members')}`}
>
<MembersDataTable
data={result.data}
total={result.total}
page={page}
pageSize={PAGE_SIZE}
account={account}
accountId={acct.id}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
}),
)}
/>
</CmsPageShell>
accountId={acct.id}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
}),
)}
departments={(departments ?? []).map((d) => ({
id: String(d.id),
name: String(d.name),
memberCount: d.memberCount,
}))}
/>
);
}

View File

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