semblance-dev/src/pages/FocusGroups.tsx

575 lines
23 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import Navigation from '@/components/Navigation';
import FocusGroupModerator from '@/components/FocusGroupModerator';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Search, Filter, MessageSquare, Users, Calendar, PlayCircle, ChevronRight, Clock, Loader2, Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from '@/lib/utils';
import { toastService } from '@/lib/toast';
import { focusGroupsApi } from '@/lib/api';
// Sample focus group data for fallback
const sampleFocusGroups = [
{
id: '1',
name: 'Mobile App UX Evaluation',
status: 'completed',
participants: 6,
date: '2023-06-10T14:00:00Z',
duration: 60,
topic: 'user-experience',
},
{
id: '2',
name: 'Product Feature Feedback',
status: 'scheduled',
participants: 8,
date: '2023-06-15T10:00:00Z',
duration: 90,
topic: 'product-feedback',
},
{
id: '3',
name: 'Marketing Campaign Testing',
status: 'in-progress',
participants: 5,
date: '2023-06-12T15:30:00Z',
duration: 45,
topic: 'creative-testing',
},
{
id: '4',
name: 'Website Navigation Study',
status: 'scheduled',
participants: 7,
date: '2023-06-18T13:00:00Z',
duration: 60,
topic: 'user-experience',
},
];
const statusColors = {
'completed': 'bg-green-100 text-green-800 border-green-200',
'scheduled': 'bg-blue-100 text-blue-800 border-blue-200',
'in-progress': 'bg-amber-100 text-amber-800 border-amber-200',
'active': 'bg-amber-100 text-amber-800 border-amber-200',
'paused': 'bg-purple-100 text-purple-800 border-purple-200',
'new': 'bg-slate-100 text-slate-800 border-slate-200',
'ai_mode': 'bg-amber-100 text-amber-800 border-amber-200',
'draft': 'bg-gray-100 text-gray-800 border-gray-200',
};
interface FocusGroup {
id?: string;
_id?: string;
name: string;
status: string;
participants?: string[] | any[];
participants_count?: number;
date: string;
duration: number;
topic: string;
discussionGuide?: string;
created_at?: string;
created_by?: string;
updated_at?: string;
}
const FocusGroups = () => {
console.log('FocusGroups component rendering');
const [mode, setMode] = useState<'view' | 'create'>('view');
const [searchTerm, setSearchTerm] = useState('');
const [focusGroups, setFocusGroups] = useState<FocusGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingGroups, setIsDeletingGroups] = useState(false);
const [draftToEdit, setDraftToEdit] = useState<FocusGroup | null>(null);
const navigate = useNavigate();
const location = useLocation();
const [preSelectedParticipants, setPreSelectedParticipants] = useState<string[]>([]);
// Use a ref to track component mounted state
const isMounted = useRef(true);
// Create a function to fetch focus groups that can be called when needed
const fetchFocusGroups = async (isMountedCheck = true) => {
console.log('fetchFocusGroups called with isMountedCheck:', isMountedCheck);
console.log('isMounted.current:', isMounted.current);
// Skip if component is not mounted (when called from useEffect cleanup)
if (isMountedCheck && !isMounted.current) {
console.log('Exiting early: component not mounted');
return;
}
console.log('Setting loading to true and making API call');
setIsLoading(true);
try {
console.log('Calling focusGroupsApi.getAll()');
const response = await focusGroupsApi.getAll();
console.log('API response received:', response);
if (!isMountedCheck || isMounted.current) {
// Process the data to ensure consistent format
const processedGroups = response.data.map((group: FocusGroup) => ({
...group,
// Ensure id is set for compatibility
id: group.id || group._id,
// Fix participants display
participants_count: Array.isArray(group.participants)
? group.participants.length
: (typeof group.participants === 'number' ? group.participants : 0)
}));
setFocusGroups(processedGroups);
}
} catch (error) {
console.error("Error fetching focus groups:", error);
if (!isMountedCheck || isMounted.current) {
toastService.error("Failed to load focus groups");
// Fallback to sample data
setFocusGroups(sampleFocusGroups);
}
} finally {
if (!isMountedCheck || isMounted.current) {
setIsLoading(false);
}
}
};
// Fetch a specific focus group for editing
const fetchFocusGroupForEdit = async (focusGroupId: string) => {
try {
const response = await focusGroupsApi.getById(focusGroupId);
if (response && response.data) {
setDraftToEdit(response.data);
setMode('create'); // Use create mode for editing
}
} catch (error) {
console.error('Error fetching focus group for edit:', error);
toastService.error("Failed to load focus group for editing");
}
};
useEffect(() => {
console.log('useEffect running - about to fetch focus groups');
// Fetch focus groups on initial load
fetchFocusGroups();
// Cleanup function to prevent memory leaks
return () => {
console.log('useEffect cleanup - setting isMounted to false');
isMounted.current = false;
};
}, []);
// Refetch when mode changes from create back to view
useEffect(() => {
console.log('Mode change useEffect running, mode:', mode);
if (mode === 'view') {
console.log('Mode is view, calling fetchFocusGroups');
fetchFocusGroups();
}
}, [mode]);
// Handle navigation state for pre-selected participants
useEffect(() => {
const state = location.state as { mode?: string; preSelectedParticipants?: string[] } | null;
if (state?.mode === 'create' && state?.preSelectedParticipants) {
setPreSelectedParticipants(state.preSelectedParticipants);
setMode('create');
// Clear the navigation state to prevent re-triggering
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate]);
// Handle URL query parameters for navigation from persona details
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const urlMode = searchParams.get('mode');
const focusGroupId = searchParams.get('id');
const tab = searchParams.get('tab');
if (urlMode === 'create') {
// For new focus group creation, switch to create mode
setMode('create');
setDraftToEdit(null);
} else if (urlMode === 'edit' && focusGroupId) {
// For editing existing focus group, find and load the draft
const foundGroup = focusGroups.find(group =>
(group._id || group.id) === focusGroupId
);
if (foundGroup) {
setDraftToEdit(foundGroup);
setMode('create'); // Use create mode for editing
} else {
// If group not found, try to fetch it from API
fetchFocusGroupForEdit(focusGroupId);
}
}
// Clear URL parameters after processing to avoid re-triggering
if (urlMode || focusGroupId || tab) {
const newUrl = location.pathname;
navigate(newUrl, { replace: true });
}
}, [location.search, focusGroups, navigate, location.pathname]);
const filteredGroups = focusGroups.filter(group =>
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
group.topic.toLowerCase().includes(searchTerm.toLowerCase())
);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
// Handle checkbox selection
const toggleGroupSelection = (groupId: string) => {
setSelectedGroups(current => {
if (current.includes(groupId)) {
return current.filter(id => id !== groupId);
} else {
return [...current, groupId];
}
});
};
// Delete selected focus groups
const deleteSelectedGroups = async () => {
if (selectedGroups.length === 0) return;
setIsDeletingGroups(true);
try {
// Delete each selected focus group
const deletePromises = selectedGroups.map(groupId =>
focusGroupsApi.delete(groupId)
);
await Promise.all(deletePromises);
// Update the UI by removing the deleted groups
setFocusGroups(current =>
current.filter(group => !selectedGroups.includes(group.id || group._id || ''))
);
// Reset selection
setSelectedGroups([]);
toastService.success(`${selectedGroups.length} focus group${selectedGroups.length > 1 ? 's' : ''} deleted successfully`);
} catch (error) {
console.error("Error deleting focus groups:", error);
toastService.error("Failed to delete focus groups");
} finally {
setIsDeletingGroups(false);
setDeleteDialogOpen(false);
}
};
return (
<div className="min-h-screen bg-slate-50">
<Navigation />
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8">
<div>
<h1 className="font-sf text-3xl font-bold text-slate-900">Focus Groups</h1>
<p className="text-slate-600 mt-1">Set up and manage AI-moderated research sessions</p>
</div>
<div className="mt-4 sm:mt-0">
<Button
onClick={() => {
console.log('Create New Focus Group button clicked, current mode:', mode);
try {
if (mode === 'view') {
console.log('Setting draft to null and switching to create mode');
setDraftToEdit(null); // Clear any draft when creating new
setMode('create');
} else {
console.log('Switching back to view mode');
setMode('view');
}
} catch (error) {
console.error('Error in Create New Focus Group onClick:', error);
}
}}
className="hover-transition"
>
{mode === 'view' ? 'Create New Focus Group' : 'View All Focus Groups'}
</Button>
</div>
</div>
{mode === 'view' ? (
<>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search focus groups by name or topic..."
className="pl-10 bg-white"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button variant="outline" className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span>Filter</span>
</Button>
</div>
<div className="glass-panel rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
<h2 className="font-sf text-xl font-semibold">Your Focus Groups</h2>
</div>
{selectedGroups.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
disabled={isDeletingGroups}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Selected ({selectedGroups.length})
</Button>
)}
</div>
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : filteredGroups.length > 0 ? (
<div className="space-y-4">
{filteredGroups.map((group) => (
<div
key={group.id}
className="glass-card rounded-xl overflow-hidden hover:shadow-md button-transition"
>
<div className="flex flex-col md:flex-row">
<div className="flex-1 p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Checkbox
id={`select-${group.id || group._id}`}
checked={selectedGroups.includes(group.id || group._id || '')}
onCheckedChange={() => toggleGroupSelection(group.id || group._id || '')}
className="mt-1"
/>
<div>
<h3 className="font-sf text-lg font-semibold mb-2">{group.name}</h3>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(group.date)}
</div>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1" />
{formatTime(group.date)}
</div>
<div className="flex items-center">
<Users className="h-4 w-4 mr-1" />
{/* Use participants_count if available, otherwise calculate from participants array */}
{group.participants_count ||
(Array.isArray(group.participants) ? group.participants.length : 0)} participant{
(group.participants_count > 1 ||
(Array.isArray(group.participants) && group.participants.length > 1)) ? 's' : ''}
</div>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1" />
{group.duration} min
</div>
</div>
</div>
</div>
<div className={cn(
"px-3 py-1 rounded-full text-xs font-medium border",
statusColors[group.status as keyof typeof statusColors] || 'bg-gray-100 text-gray-800 border-gray-200'
)}>
{group.status === 'completed' && 'Completed'}
{group.status === 'scheduled' && 'Scheduled'}
{group.status === 'in-progress' && 'In Progress'}
{group.status === 'active' && 'In Progress'}
{group.status === 'ai_mode' && 'In Progress'}
{group.status === 'paused' && 'Paused'}
{group.status === 'new' && 'Not Started'}
{group.status === 'draft' && 'Draft'}
{!['completed', 'scheduled', 'in-progress', 'active', 'ai_mode', 'paused', 'new', 'draft'].includes(group.status) && group.status}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<div className="px-3 py-1 bg-slate-100 rounded-full text-xs font-medium text-slate-800">
{group.topic === 'user-experience' && 'User Experience'}
{group.topic === 'product-feedback' && 'Product Feedback'}
{group.topic === 'creative-testing' && 'Creative Testing'}
{group.topic === 'messaging-evaluation' && 'Messaging Evaluation'}
{group.topic && !['user-experience', 'product-feedback', 'creative-testing', 'messaging-evaluation'].includes(group.topic) &&
group.topic.charAt(0).toUpperCase() + group.topic.slice(1).replace(/-/g, ' ')}
</div>
<div className="px-3 py-1 bg-slate-100 rounded-full text-xs font-medium text-slate-800">
AI Moderated
</div>
</div>
</div>
<div className="bg-slate-50 p-6 flex flex-col justify-center items-center md:border-l border-slate-100">
<Button
variant={
(group.status === 'in-progress' || group.status === 'active' || group.status === 'ai_mode') ? 'default' :
group.status === 'new' ? 'outline' :
group.status === 'draft' ? 'outline' :
'default'
}
className={cn(
"w-full hover-transition",
group.status === 'new' ? 'bg-slate-200 text-slate-700 hover:bg-slate-300 border-slate-300' : '',
group.status === 'draft' ? 'bg-gray-200 text-gray-700 hover:bg-gray-300 border-gray-300' : ''
)}
onClick={() => {
if (group.status === 'draft') {
// Set the draft to edit and switch to create mode
setDraftToEdit(group);
setMode('create');
} else {
// Navigate to the focus group session with the correct ID
const focusGroupId = group.id || group._id;
console.log("Navigating to focus group:", focusGroupId);
navigate(`/focus-groups/${focusGroupId}`);
}
}}
>
{group.status === 'completed' ? (
<>
View Session
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : group.status === 'in-progress' || group.status === 'active' || group.status === 'ai_mode' ? (
<>
Join Session
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : group.status === 'paused' ? (
<>
Session Details
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : group.status === 'scheduled' ? (
<>
View Details
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : group.status === 'new' ? (
<>
View Session
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : group.status === 'draft' ? (
<>
Edit
<ChevronRight className="ml-2 h-4 w-4" />
</>
) : (
<>
View Session
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No focus groups found matching your search criteria.</p>
</div>
)}
</div>
</>
) : (
<FocusGroupModerator
draftToEdit={draftToEdit}
preSelectedParticipants={preSelectedParticipants}
onDraftSaved={() => {
setDraftToEdit(null);
setMode('view');
setPreSelectedParticipants([]);
fetchFocusGroups();
}}
/>
)}
</main>
{/* Confirmation Dialog for Delete */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedGroups.length} Focus Group{selectedGroups.length !== 1 ? 's' : ''}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected focus group{selectedGroups.length !== 1 ? 's' : ''} and remove all data associated with {selectedGroups.length !== 1 ? 'them' : 'it'}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingGroups}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
deleteSelectedGroups();
}}
disabled={isDeletingGroups}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingGroups ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>Delete</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default FocusGroups;