575 lines
23 KiB
TypeScript
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;
|