feat: enhance member management features; add quick stats and search capabilities
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import {
|
||||
type RowSelectionState,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table';
|
||||
import { ChevronLeft, ChevronRight, Plus, Users } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
import { MemberQuickPreview } from './member-quick-preview';
|
||||
import { createMembersColumns, type MemberRow } from './members-table-columns';
|
||||
import { MembersToolbar } from './members-toolbar';
|
||||
|
||||
interface MembersListViewProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
accountId: string;
|
||||
duesCategories: Array<{ id: string; name: string }>;
|
||||
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||
}
|
||||
|
||||
export function MembersListView({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
accountId,
|
||||
duesCategories,
|
||||
departments,
|
||||
}: MembersListViewProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [previewMember, setPreviewMember] = useState<MemberRow | null>(null);
|
||||
|
||||
const members: MemberRow[] = data.map((m) => ({
|
||||
id: String(m.id),
|
||||
firstName: String(m.first_name ?? ''),
|
||||
lastName: String(m.last_name ?? ''),
|
||||
email: String(m.email ?? ''),
|
||||
phone: String(m.phone ?? ''),
|
||||
mobile: String(m.mobile ?? ''),
|
||||
memberNumber: String(m.member_number ?? ''),
|
||||
city: String(m.city ?? ''),
|
||||
status: String(m.status ?? 'active'),
|
||||
entryDate: String(m.entry_date ?? ''),
|
||||
isHonorary: Boolean(m.is_honorary),
|
||||
isFoundingMember: Boolean(m.is_founding_member),
|
||||
isYouth: Boolean(m.is_youth),
|
||||
}));
|
||||
|
||||
const columns = createMembersColumns({
|
||||
account,
|
||||
onPreview: setPreviewMember,
|
||||
onNavigate: (path) => router.push(path),
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: members,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: { rowSelection },
|
||||
getRowId: (row) => row.id,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(total / pageSize),
|
||||
});
|
||||
|
||||
const selectedIds = Object.keys(rowSelection).filter(
|
||||
(key) => rowSelection[key],
|
||||
);
|
||||
|
||||
const updateSearchParam = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
if (key !== 'page') params.delete('page');
|
||||
router.push(`?${params.toString()}`);
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const hasFilters = searchParams.get('q') || searchParams.get('status');
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MembersToolbar
|
||||
account={account}
|
||||
accountId={accountId}
|
||||
searchValue={searchParams.get('q') ?? ''}
|
||||
statusFilter={searchParams.get('status') ?? ''}
|
||||
onSearchChange={(value) => updateSearchParam('q', value || null)}
|
||||
onStatusChange={(value) => updateSearchParam('status', value || null)}
|
||||
selectedCount={selectedIds.length}
|
||||
selectedIds={selectedIds}
|
||||
departments={departments}
|
||||
duesCategories={duesCategories}
|
||||
/>
|
||||
|
||||
{/* Table or empty state */}
|
||||
{total === 0 && !hasFilters ? (
|
||||
<EmptyState account={account} />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40 sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="whitespace-nowrap">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className="cursor-pointer transition-colors"
|
||||
onClick={() => setPreviewMember(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-muted-foreground h-32 text-center"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Keine Mitglieder gefunden</p>
|
||||
<p className="text-sm">
|
||||
Versuchen Sie andere Suchbegriffe oder Filter.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm tabular-nums">
|
||||
{total} Mitglieder
|
||||
{selectedIds.length > 0 && ` — ${selectedIds.length} ausgewählt`}
|
||||
</p>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => updateSearchParam('page', String(page - 1))}
|
||||
data-test="members-prev-page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => updateSearchParam('page', String(page + 1))}
|
||||
data-test="members-next-page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quick preview sheet */}
|
||||
<MemberQuickPreview
|
||||
member={previewMember}
|
||||
account={account}
|
||||
open={!!previewMember}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPreviewMember(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
|
||||
function EmptyState({ account }: { account: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
|
||||
<div className="bg-muted mb-4 flex size-16 items-center justify-center rounded-full">
|
||||
<Users className="text-muted-foreground size-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Noch keine Mitglieder</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-center text-sm">
|
||||
Erstellen Sie Ihr erstes Mitglied oder importieren Sie bestehende
|
||||
Mitgliederdaten aus einer CSV-Datei.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link
|
||||
href={`/home/${account}/members-cms/new`}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
data-test="empty-create-member"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Mitglied erstellen
|
||||
</Link>
|
||||
<Link
|
||||
href={`/home/${account}/members-cms/import`}
|
||||
className="border-border bg-background hover:bg-muted inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition-colors"
|
||||
>
|
||||
CSV importieren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user