307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
'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 }>;
|
|
tags?: Array<{ id: string; name: string; color: string }>;
|
|
}
|
|
|
|
export function MembersToolbar({
|
|
account,
|
|
accountId,
|
|
searchValue,
|
|
statusFilter,
|
|
onSearchChange,
|
|
onStatusChange,
|
|
selectedCount,
|
|
selectedIds,
|
|
departments,
|
|
duesCategories,
|
|
tags = [],
|
|
}: 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>
|
|
);
|
|
}
|