feat: enhance member management features; add quick stats and search capabilities
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Download, Filter, Plus, Search, X } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
|
||||
|
||||
import {
|
||||
exportMembers,
|
||||
exportMembersExcel,
|
||||
bulkUpdateStatus,
|
||||
bulkArchiveMembers,
|
||||
} from '../server/actions/member-actions';
|
||||
import { MembersFilterPanel } from './members-filter-panel';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'Alle Status' },
|
||||
{ value: 'active', label: 'Aktiv' },
|
||||
{ value: 'inactive', label: 'Inaktiv' },
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'resigned', label: 'Ausgetreten' },
|
||||
] as const;
|
||||
|
||||
interface MembersToolbarProps {
|
||||
account: string;
|
||||
accountId: string;
|
||||
searchValue: string;
|
||||
statusFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
selectedCount: number;
|
||||
selectedIds: string[];
|
||||
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||
duesCategories: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function MembersToolbar({
|
||||
account,
|
||||
accountId,
|
||||
searchValue,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
departments,
|
||||
duesCategories,
|
||||
}: MembersToolbarProps) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [searchInput, setSearchInput] = useState(searchValue);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const { execute: csvDownload, isPending: isCsvDownloading } =
|
||||
useFileDownloadAction(exportMembers);
|
||||
const { execute: excelDownload, isPending: isExcelDownloading } =
|
||||
useFileDownloadAction(exportMembersExcel);
|
||||
|
||||
// Debounced search — auto-triggers after 300ms
|
||||
useEffect(() => {
|
||||
if (searchInput === searchValue) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onSearchChange(searchInput);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchInput, searchValue, onSearchChange]);
|
||||
|
||||
const { execute: executeBulkStatus } = useAction(bulkUpdateStatus, {
|
||||
onSuccess: () => {
|
||||
toast.success('Status aktualisiert');
|
||||
startTransition(() => router.refresh());
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const { execute: executeBulkArchive } = useAction(bulkArchiveMembers, {
|
||||
onSuccess: () => {
|
||||
toast.success('Mitglieder archiviert');
|
||||
setArchiveDialogOpen(false);
|
||||
startTransition(() => router.refresh());
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Archivieren'),
|
||||
});
|
||||
|
||||
// Detect OS for shortcut hint
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac');
|
||||
const shortcutHint = isMac ? '⌘K' : 'Ctrl+K';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Main toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md min-w-[200px] flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Suchen..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pr-16 pl-9"
|
||||
data-test="members-search"
|
||||
/>
|
||||
<div className="absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1">
|
||||
{searchInput ? (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
onSearchChange('');
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{shortcutHint}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => onStatusChange(v === 'all' ? '' : (v ?? ''))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[150px]"
|
||||
data-test="members-status-filter"
|
||||
>
|
||||
<SelectValue placeholder="Alle Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value || 'all'} value={opt.value || 'all'}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Advanced filter */}
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger
|
||||
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1 rounded-lg border px-2.5 text-sm font-medium"
|
||||
data-test="members-filter-btn"
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
<span className="hidden sm:inline">Filter</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="start">
|
||||
<MembersFilterPanel
|
||||
departments={departments}
|
||||
duesCategories={duesCategories}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Export — collapsed on mobile */}
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
csvDownload({
|
||||
accountId,
|
||||
status: statusFilter || undefined,
|
||||
format: 'csv' as const,
|
||||
})
|
||||
}
|
||||
disabled={isCsvDownloading}
|
||||
data-test="members-export-csv"
|
||||
>
|
||||
<Download className="mr-1.5 size-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
excelDownload({
|
||||
accountId,
|
||||
status: statusFilter || undefined,
|
||||
format: 'excel' as const,
|
||||
})
|
||||
}
|
||||
disabled={isExcelDownloading}
|
||||
data-test="members-export-excel"
|
||||
>
|
||||
<Download className="mr-1.5 size-4" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create */}
|
||||
<Link
|
||||
href={`/home/${account}/members-cms/new`}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-sm font-medium transition-colors"
|
||||
data-test="members-create-btn"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span className="hidden sm:inline">Neues Mitglied</span>
|
||||
<span className="sm:hidden">Neu</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="bg-muted/50 border-primary/20 flex items-center gap-3 rounded-lg border px-4 py-2">
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{selectedCount} ausgewählt
|
||||
</Badge>
|
||||
|
||||
<Select
|
||||
onValueChange={(status) =>
|
||||
executeBulkStatus({
|
||||
memberIds: selectedIds,
|
||||
accountId,
|
||||
status: status as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Status ändern" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Aktiv setzen</SelectItem>
|
||||
<SelectItem value="inactive">Inaktiv setzen</SelectItem>
|
||||
<SelectItem value="pending">Ausstehend setzen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setArchiveDialogOpen(true)}
|
||||
data-test="members-bulk-archive"
|
||||
>
|
||||
Archivieren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archive confirmation dialog */}
|
||||
<AlertDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mitglieder archivieren</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchten Sie {selectedCount} Mitglied
|
||||
{selectedCount > 1 ? 'er' : ''} wirklich archivieren?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
executeBulkArchive({ memberIds: selectedIds, accountId })
|
||||
}
|
||||
>
|
||||
Archivieren
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user