275 lines
9.0 KiB
TypeScript
275 lines
9.0 KiB
TypeScript
'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 }>;
|
|
tags?: Array<{ id: string; name: string; color: string }>;
|
|
memberTags?: Record<
|
|
string,
|
|
Array<{ id: string; name: string; color: string }>
|
|
>;
|
|
}
|
|
|
|
export function MembersListView({
|
|
data,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
account,
|
|
accountId,
|
|
duesCategories,
|
|
departments,
|
|
tags = [],
|
|
memberTags = {},
|
|
}: 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),
|
|
tags: memberTags[String(m.id)] ?? [],
|
|
}));
|
|
|
|
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}
|
|
tags={tags}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|