diff --git a/client/src/api/types/requests/index.ts b/client/src/api/types/requests/index.ts new file mode 100644 index 0000000..41e7399 --- /dev/null +++ b/client/src/api/types/requests/index.ts @@ -0,0 +1,11 @@ +export * from './ai'; +export * from './annotations'; +export * from './assistant'; +export * from './audio'; +export * from './integrations'; +export * from './meetings'; +export * from './oidc'; +export * from './preferences'; +export * from './recording-apps'; +export * from './templates'; +export * from './triggers'; diff --git a/client/src/hooks/meetings/use-meeting-mutations.ts b/client/src/hooks/meetings/use-meeting-mutations.ts index 20c007e..11d6ff3 100644 --- a/client/src/hooks/meetings/use-meeting-mutations.ts +++ b/client/src/hooks/meetings/use-meeting-mutations.ts @@ -4,7 +4,7 @@ import { meetingCache } from '@/lib/cache/meeting-cache'; import { getAPI } from '@/api/interface'; import type { Meeting } from '@/api/types'; import type { CreateMeetingRequest, DeleteMeetingsResult } from '@/api/types/requests/meetings'; -import { useToast } from '@/hooks/use-toast'; +import { useToast } from '@/hooks/ui/use-toast'; import { useQueryClient } from '@tanstack/react-query'; interface CreateMeetingContext { diff --git a/client/src/pages/Meetings.tsx b/client/src/pages/Meetings.tsx index 6a5a675..72185f3 100644 --- a/client/src/pages/Meetings.tsx +++ b/client/src/pages/Meetings.tsx @@ -7,15 +7,17 @@ import { getAPI } from '@/api/interface'; import type { Meeting, MeetingState } from '@/api/types'; import type { ProjectScope } from '@/api/types/requests'; import { EmptyState } from '@/components/common'; -import { MeetingCard } from '@/components/features/meetings'; +import { BulkActionToolbar, MeetingCard } from '@/components/features/meetings'; import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter'; import { Button } from '@/components/ui/button'; +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { Input } from '@/components/ui/input'; import { SearchIcon } from '@/components/ui/search-icon'; import { SkeletonMeetingCard } from '@/components/ui/skeleton'; import { UpcomingMeetings } from '@/components/features/calendar'; import { useProjects } from '@/contexts/project-state'; import { useGuardedMutation } from '@/hooks'; +import { useDeleteMeetings } from '@/hooks/meetings/use-meeting-mutations'; import { addClientLog } from '@/lib/observability/client'; import { preferences } from '@/lib/preferences'; import { MEETINGS_PAGE_LIMIT, SKELETON_CARDS_COUNT } from '@/lib/constants/timing'; @@ -49,6 +51,11 @@ export default function MeetingsPage() { const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); + // Bulk selection state + const [selectedMeetingIds, setSelectedMeetingIds] = useState>(new Set()); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + const { mutate: deleteMeetings, isLoading: isDeleting } = useDeleteMeetings(); + const shouldSkipFetch = (projectScope === 'selected' && selectedProjectIds.length === 0) || (projectScope === 'active' && !resolvedProjectId && !projectsLoading); @@ -107,11 +114,57 @@ export default function MeetingsPage() { preferences.setMeetingsProjectFilter(projectScope, selectedProjectIds); }, [projectScope, selectedProjectIds]); + // Clear selections when filters change + useEffect(() => { + setSelectedMeetingIds(new Set()); + }, [searchQuery, stateFilter, projectScope, resolvedProjectId]); + const filteredMeetings = useMemo( () => meetings.filter((m) => m.title.toLowerCase().includes(searchQuery.toLowerCase())), [meetings, searchQuery] ); + const deletableMeetings = useMemo( + () => meetings.filter((m) => m.state !== 'recording'), + [meetings] + ); + + const deletableMeetingIds = useMemo( + () => new Set(deletableMeetings.map((m) => m.id)), + [deletableMeetings] + ); + + const handleSelect = useCallback((meetingId: string, selected: boolean) => { + setSelectedMeetingIds((prev) => { + const next = new Set(prev); + if (selected) next.add(meetingId); + else next.delete(meetingId); + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + setSelectedMeetingIds(new Set(deletableMeetingIds)); + }, [deletableMeetingIds]); + + const handleDeselectAll = useCallback(() => { + setSelectedMeetingIds(new Set()); + }, []); + + const handleBulkDelete = useCallback(() => { + setShowBulkDeleteDialog(true); + }, []); + + const confirmBulkDelete = useCallback(() => { + deleteMeetings(Array.from(selectedMeetingIds), { + onSuccess: () => { + setSelectedMeetingIds(new Set()); + setShowBulkDeleteDialog(false); + fetchMeetings(0, false); + }, + }); + }, [deleteMeetings, selectedMeetingIds, fetchMeetings]); + const hasMore = meetings.length < totalCount; const handleLoadMore = () => { @@ -209,6 +262,9 @@ export default function MeetingsPage() { index={i} onDelete={handleDelete} showDeleteButton + isSelectable={meeting.state !== 'recording'} + isSelected={selectedMeetingIds.has(meeting.id)} + onSelect={handleSelect} /> ))} @@ -234,6 +290,26 @@ export default function MeetingsPage() { )} + + + + ); }