Added onSuccess callback to useDeleteMeetings hook and use it in Meetings.tsx to close dialog, clear selection, and exit selection mode. Removed flaky useEffect that tried to detect deletion success by checking if meetings still existed in the list.
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
// Meetings list page
|
|
|
|
import { Calendar, Loader2, CheckSquare } from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
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 { 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';
|
|
|
|
const FILTER_STATES = ['all', 'completed', 'stopped', 'recording'] as const;
|
|
type FilterState = (typeof FILTER_STATES)[number];
|
|
|
|
export default function MeetingsPage() {
|
|
const { projectId } = useParams<{ projectId: string }>();
|
|
const { activeProject, projects, isLoading: projectsLoading } = useProjects();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [stateFilter, setStateFilter] = useState<FilterState>('all');
|
|
const [projectScope, setProjectScope] = useState<ProjectScope>(() => {
|
|
return preferences.get().meetings_project_scope ?? 'active';
|
|
});
|
|
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>(() => {
|
|
return preferences.get().meetings_project_ids ?? [];
|
|
});
|
|
const { guard } = useGuardedMutation();
|
|
const resolvedProjectId = projectId ?? activeProject?.id;
|
|
|
|
const selectedIdsRef = useRef(selectedProjectIds);
|
|
selectedIdsRef.current = selectedProjectIds;
|
|
const activeProjects = useMemo(
|
|
() => projects.filter((project) => !project.is_archived),
|
|
[projects]
|
|
);
|
|
|
|
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
// Bulk selection state
|
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<string>>(new Set());
|
|
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
|
|
|
const handleDeleteSuccess = useCallback(() => {
|
|
setShowBulkDeleteDialog(false);
|
|
setSelectedMeetingIds(new Set());
|
|
setIsSelectionMode(false);
|
|
}, []);
|
|
|
|
const { mutate: deleteMeetings, isLoading: isDeleting } = useDeleteMeetings({
|
|
onSuccess: handleDeleteSuccess,
|
|
});
|
|
|
|
const shouldSkipFetch =
|
|
(projectScope === 'selected' && selectedProjectIds.length === 0) ||
|
|
(projectScope === 'active' && !resolvedProjectId && !projectsLoading);
|
|
|
|
const fetchMeetings = useCallback(
|
|
async (offset: number, append: boolean) => {
|
|
if (shouldSkipFetch) {
|
|
setMeetings([]);
|
|
setTotalCount(0);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (append) {
|
|
setLoadingMore(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
|
|
try {
|
|
const response = await getAPI().listMeetings({
|
|
limit: MEETINGS_PAGE_LIMIT,
|
|
offset,
|
|
states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState],
|
|
project_id: projectScope === 'active' ? resolvedProjectId : undefined,
|
|
project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined,
|
|
include_segments: true,
|
|
});
|
|
if (append) {
|
|
setMeetings((prev) => [...prev, ...response.meetings]);
|
|
} else {
|
|
setMeetings(response.meetings);
|
|
}
|
|
setTotalCount(response.total_count);
|
|
} catch (error) {
|
|
addClientLog({
|
|
level: 'warning',
|
|
source: 'app',
|
|
message: 'Failed to fetch meeting list',
|
|
details: error instanceof Error ? error.message : String(error),
|
|
metadata: { context: 'meetings_list_fetch' },
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[shouldSkipFetch, stateFilter, projectScope, resolvedProjectId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchMeetings(0, false);
|
|
}, [fetchMeetings]);
|
|
|
|
useEffect(() => {
|
|
preferences.setMeetingsProjectFilter(projectScope, selectedProjectIds);
|
|
}, [projectScope, selectedProjectIds]);
|
|
|
|
// Clear selections when filters change
|
|
useEffect(() => {
|
|
setSelectedMeetingIds(new Set());
|
|
setIsSelectionMode(false);
|
|
}, []);
|
|
|
|
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());
|
|
setIsSelectionMode(false);
|
|
}, []);
|
|
|
|
const toggleSelectionMode = useCallback(() => {
|
|
setIsSelectionMode((prev) => {
|
|
if (prev) setSelectedMeetingIds(new Set());
|
|
return !prev;
|
|
});
|
|
}, []);
|
|
|
|
const handleBulkDelete = useCallback(() => {
|
|
setShowBulkDeleteDialog(true);
|
|
}, []);
|
|
|
|
const confirmBulkDelete = useCallback(() => {
|
|
deleteMeetings(Array.from(selectedMeetingIds));
|
|
}, [deleteMeetings, selectedMeetingIds]);
|
|
|
|
const hasMore = meetings.length < totalCount;
|
|
|
|
const handleLoadMore = () => {
|
|
fetchMeetings(meetings.length, true);
|
|
};
|
|
|
|
const handleDelete = async (meetingId: string, e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (confirm('Are you sure you want to delete this meeting?')) {
|
|
const result = await guard(async () => getAPI().deleteMeeting(meetingId), {
|
|
title: 'Offline mode',
|
|
message: 'Deleting meetings requires an active server connection.',
|
|
});
|
|
if (result) {
|
|
fetchMeetings(0, false);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-foreground">Meetings</h1>
|
|
</div>
|
|
|
|
<ProjectScopeFilter
|
|
activeProjects={activeProjects}
|
|
projectScope={projectScope}
|
|
selectedProjectIds={selectedProjectIds}
|
|
onProjectScopeChange={setProjectScope}
|
|
onSelectedProjectIdsChange={setSelectedProjectIds}
|
|
projectsLoading={projectsLoading}
|
|
resolvedProjectId={resolvedProjectId}
|
|
idPrefix="meeting"
|
|
/>
|
|
|
|
{/* Upcoming Meetings from Calendars */}
|
|
<UpcomingMeetings maxEvents={8} />
|
|
|
|
{/* Past Recordings */}
|
|
<div className="pt-4">
|
|
<h2 className="text-lg font-semibold text-foreground mb-4">Past Recordings</h2>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-3 mb-4">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<SearchIcon />
|
|
<Input
|
|
placeholder="Search meetings..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{FILTER_STATES.map((state) => (
|
|
<Button
|
|
key={state}
|
|
variant={stateFilter === state ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setStateFilter(state)}
|
|
className="capitalize"
|
|
>
|
|
{state}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
variant={isSelectionMode ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={toggleSelectionMode}
|
|
>
|
|
<CheckSquare className="h-4 w-4 mr-1" />
|
|
Select
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meetings Grid */}
|
|
{loading ? (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: SKELETON_CARDS_COUNT }, (_, i) => (
|
|
<SkeletonMeetingCard key={i} />
|
|
))}
|
|
</div>
|
|
) : filteredMeetings.length === 0 ? (
|
|
<EmptyState
|
|
icon={Calendar}
|
|
title="No meetings found"
|
|
description={
|
|
searchQuery
|
|
? 'Try a different search term'
|
|
: 'Start recording to create your first meeting'
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{filteredMeetings.map((meeting, i) => (
|
|
<MeetingCard
|
|
key={meeting.id}
|
|
meeting={meeting}
|
|
index={i}
|
|
onDelete={handleDelete}
|
|
showDeleteButton={!isSelectionMode}
|
|
isSelectable={isSelectionMode && meeting.state !== 'recording'}
|
|
isSelected={selectedMeetingIds.has(meeting.id)}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
{hasMore && !searchQuery && (
|
|
<div className="flex justify-center mt-6">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleLoadMore}
|
|
disabled={loadingMore}
|
|
className="min-w-[140px]"
|
|
>
|
|
{loadingMore ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
`Load More (${totalCount - meetings.length} remaining)`
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<BulkActionToolbar
|
|
selectedCount={selectedMeetingIds.size}
|
|
totalCount={deletableMeetings.length}
|
|
isDeleting={isDeleting}
|
|
onSelectAll={handleSelectAll}
|
|
onDeselectAll={handleDeselectAll}
|
|
onDelete={handleBulkDelete}
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
open={showBulkDeleteDialog}
|
|
onOpenChange={setShowBulkDeleteDialog}
|
|
onConfirm={confirmBulkDelete}
|
|
title="Delete Meetings"
|
|
description={`Are you sure you want to delete ${selectedMeetingIds.size} meeting(s)? This action cannot be undone.`}
|
|
confirmText="Delete"
|
|
variant="destructive"
|
|
isLoading={isDeleting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|