feat: enhance member management features; add quick stats and search capabilities
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
FileDown,
|
||||
FileUp,
|
||||
IdCard,
|
||||
KeyRound,
|
||||
LayoutList,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
MemberStatsBar,
|
||||
MemberCommandPalette,
|
||||
} from '@kit/member-management/components';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface MembersCmsLayoutClientProps {
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
account: string;
|
||||
accountId: string;
|
||||
stats: {
|
||||
total: number;
|
||||
active: number;
|
||||
pending: number;
|
||||
newThisYear: number;
|
||||
pendingApplications: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function MembersCmsLayoutClient({
|
||||
header,
|
||||
children,
|
||||
account,
|
||||
accountId,
|
||||
stats,
|
||||
}: MembersCmsLayoutClientProps) {
|
||||
const pathname = usePathname();
|
||||
const basePath = `/home/${account}/members-cms`;
|
||||
|
||||
const isOnMembersTab =
|
||||
pathname.endsWith('/members-cms') ||
|
||||
pathname.includes('/members-cms/new') ||
|
||||
/\/members-cms\/[^/]+$/.test(pathname);
|
||||
const isOnApplicationsTab = pathname.includes('/applications');
|
||||
const isOnSubPage =
|
||||
pathname.includes('/import') ||
|
||||
pathname.includes('/edit') ||
|
||||
(/\/members-cms\/[^/]+$/.test(pathname) &&
|
||||
!pathname.endsWith('/members-cms'));
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
|
||||
<PageBody>
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar — only on main views */}
|
||||
{!isOnSubPage && <MemberStatsBar stats={stats} />}
|
||||
|
||||
{/* Tab navigation + settings */}
|
||||
{!isOnSubPage && (
|
||||
<div className="flex items-center justify-between border-b">
|
||||
<nav className="-mb-px flex gap-4">
|
||||
<TabLink
|
||||
href={basePath}
|
||||
active={isOnMembersTab && !isOnApplicationsTab}
|
||||
>
|
||||
<Users className="size-4" />
|
||||
Mitglieder
|
||||
</TabLink>
|
||||
<TabLink
|
||||
href={`${basePath}/applications`}
|
||||
active={isOnApplicationsTab}
|
||||
>
|
||||
<FileUp className="size-4" />
|
||||
Aufnahmeanträge
|
||||
{stats.pendingApplications > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="ml-1 h-5 min-w-5 px-1 text-xs"
|
||||
>
|
||||
{stats.pendingApplications}
|
||||
</Badge>
|
||||
)}
|
||||
</TabLink>
|
||||
</nav>
|
||||
|
||||
<SettingsMenu basePath={basePath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<MemberCommandPalette account={account} accountId={accountId} />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 border-b-2 px-1 pb-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu({ basePath }: { basePath: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const navigate = (path: string) => () => router.push(path);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded-lg"
|
||||
data-test="members-settings-menu"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/dues`)}>
|
||||
<LayoutList className="mr-2 size-4" />
|
||||
Beitragskategorien
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/departments`)}>
|
||||
<Users className="mr-2 size-4" />
|
||||
Abteilungen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
|
||||
<IdCard className="mr-2 size-4" />
|
||||
Mitgliedsausweise
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/invitations`)}>
|
||||
<KeyRound className="mr-2 size-4" />
|
||||
Portal-Einladungen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/import`)}>
|
||||
<FileDown className="mr-2 size-4" />
|
||||
Import
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { 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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
|
||||
|
||||
import { MembersCmsLayoutClient } from './_components/members-cms-layout-client';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function MembersCmsLayout({ children, params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const stats = await api.getMemberQuickStats(acct.id);
|
||||
|
||||
return (
|
||||
<MembersCmsLayoutClient
|
||||
header={
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title="Mitglieder"
|
||||
description={`${stats.total} Mitglieder verwalten`}
|
||||
/>
|
||||
}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
stats={stats}
|
||||
>
|
||||
{children}
|
||||
</MembersCmsLayoutClient>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { 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),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user