Files
myeasycms-v2/packages/features/member-management/src/components/members-toolbar.tsx
T. Zehetbauer 5c5aaabae5
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
refactor: remove obsolete member management API module
2026-04-03 14:08:31 +02:00

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>
);
}