diff --git a/src/components/FocusGroupModerator.tsx b/src/components/FocusGroupModerator.tsx index 101e0746..39b58b18 100644 --- a/src/components/FocusGroupModerator.tsx +++ b/src/components/FocusGroupModerator.tsx @@ -4,108 +4,29 @@ import { useNavigation } from '@/contexts/NavigationContext'; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { - MessageSquare, - Upload, - RefreshCw, - AlertCircle, - List, - Users, - FileText, - Play, - UploadCloud, - Loader2, - Search, - Filter, - Folder, - FolderPlus, - MoreHorizontal, - Plus, - Check, - X, - Download, - Info, - Copy -} from 'lucide-react'; +import { MessageSquare } from 'lucide-react'; import { toast } from 'sonner'; -import { personasApi, focusGroupsApi, foldersApi } from '@/lib/api'; -import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; -import { getSocket } from '@/services/websocketServiceNew'; +import { personasApi, focusGroupsApi } from '@/lib/api'; import ProgressModal from '@/components/ui/ProgressModal'; -import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer'; -import AssetUploader from '@/components/AssetUploader'; - -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter, -} from "@/components/ui/dialog"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import UserCard from "@/components/UserCard"; import { Persona } from "@/types/persona"; -// Define folder interface (database-compatible) -interface Folder { - _id: string; - id?: string; // Legacy compatibility - name: string; - created_at?: string; - created_by?: string; - updated_at?: string; -} +// Custom hooks +import { useFocusGroupAutoSave } from '@/hooks/useFocusGroupAutoSave'; +import { useFolderManagement } from '@/hooks/useFolderManagement'; +import { usePersonaFiltering } from '@/hooks/usePersonaFiltering'; +import { useDiscussionGuideGeneration } from '@/hooks/useDiscussionGuideGeneration'; -// Default folder ID for "All Personas" -const DEFAULT_FOLDER_ID = 'all'; - -// Define filter state interface -interface FilterState { - age: string[]; - gender: string[]; - occupation: string[]; - location: string[]; - techSavviness: string[]; - ethnicity: string[]; -} +// Components +import { SaveStatusIndicator } from '@/components/ui/SaveStatusIndicator'; +import { FolderSidebar } from '@/components/focus-group-session/FolderSidebar'; +import { PersonaFilterDialog } from '@/components/focus-group-session/PersonaFilterDialog'; +import { CopyGuideDialog } from '@/components/focus-group-session/CopyGuideDialog'; +import { SetupTab } from '@/components/focus-group-session/SetupTab'; +import { ReviewTab } from '@/components/focus-group-session/ReviewTab'; +import { ParticipantsTab } from '@/components/focus-group-session/ParticipantsTab'; +// Form schema const formSchema = z.object({ researchBrief: z.string().min(10, { message: "Research brief must be at least 10 characters.", @@ -124,403 +45,37 @@ const formSchema = z.object({ verbosity: z.string().optional(), }); -// Sample discussion guide sections - We'll keep this but fetch real personas from the database - -// Sample discussion guide sections -const sampleGuide = { - introduction: "Welcome to our focus group discussion. Today we'll be exploring your experiences and opinions on [product/service]. There are no right or wrong answers, we're just interested in your honest thoughts.", - warmup: "Let's start by introducing ourselves and sharing a bit about your background and daily routines relevant to this topic.", - exploration: "Now, let's dive deeper into your experiences with similar products. What features do you find most valuable? What frustrations have you encountered?", - creative: "We'll now show you some concepts and get your feedback. Please be honest and specific in your reactions.", - conclusion: "To wrap up, I'd like to hear your final thoughts on what we've discussed today and any additional insights you'd like to share." -}; - interface FocusGroupModeratorProps { draftToEdit?: any | null; onDraftSaved?: () => void; preSelectedParticipants?: string[]; } -export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSelectedParticipants = [] }: FocusGroupModeratorProps = {}) { - console.log('FocusGroupModerator component rendering, draftToEdit:', draftToEdit); +export default function FocusGroupModerator({ + draftToEdit, + onDraftSaved, + preSelectedParticipants = [] +}: FocusGroupModeratorProps = {}) { const navigate = useNavigate(); - const location = useLocation(); const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation(); - const [activeTab, setActiveTab] = useState('setup'); - - // Cancellable generation for discussion guide - const socket = getSocket(); - const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket); - const [isGuideProgressModalOpen, setIsGuideProgressModalOpen] = useState(false); - const [discussionGuide, setDiscussionGuide] = useState(null); + // Tab state + const [activeTab, setActiveTab] = useState('setup'); + + // Core state const [draftFocusGroupId, setDraftFocusGroupId] = useState(null); - - // Track if discussion guide is being edited to prevent updates during editing - const [isEditingGuide, setIsEditingGuide] = useState(false); - - // Ref to access current discussionGuide in callbacks without adding it as dependency - const discussionGuideRef = useRef(discussionGuide); - discussionGuideRef.current = discussionGuide; - - // Track if we've already loaded the draft to prevent re-loading - const draftLoadedRef = useRef(false); - - // Helper function to determine if discussion guide is JSON format - const isJsonFormat = (guide: any): boolean => { - return guide && typeof guide === 'object' && guide.title && guide.sections; - }; const [selectedParticipants, setSelectedParticipants] = useState([]); const [backendAssets, setBackendAssets] = useState([]); - const [isLoadingAssets, setIsLoadingAssets] = useState(false); const [personas, setPersonas] = useState([]); const [isLoadingPersonas, setIsLoadingPersonas] = useState(false); - - // Download state - const [isDownloadingGuide, setIsDownloadingGuide] = useState(false); - - // Copy discussion guide state + + // Copy guide dialog state const [isCopyGuideModalOpen, setIsCopyGuideModalOpen] = useState(false); - const [availableFocusGroups, setAvailableFocusGroups] = useState([]); - const [isLoadingFocusGroups, setIsLoadingFocusGroups] = useState(false); - const [copyGuideSearchTerm, setCopyGuideSearchTerm] = useState(''); - - // Debug modal state changes - React.useEffect(() => { - console.log("isCopyGuideModalOpen state changed to:", isCopyGuideModalOpen); - }, [isCopyGuideModalOpen]); - - // Debug component renders - React.useEffect(() => { - console.log("FocusGroupModerator rendered - Modal state:", isCopyGuideModalOpen, "Available focus groups:", availableFocusGroups.length); - }); - - // Folder state - const [folders, setFolders] = useState([]); - const [selectedFolder, setSelectedFolder] = useState(DEFAULT_FOLDER_ID); - const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); - const [folderToRename, setFolderToRename] = useState(null); - const [renameFolderName, setRenameFolderName] = useState(''); - - // Filter state - const [searchTerm, setSearchTerm] = useState(''); - const [isFilterOpen, setIsFilterOpen] = useState(false); - const [activeFilters, setActiveFilters] = useState({ - age: [], - gender: [], - occupation: [], - location: [], - techSavviness: [], - ethnicity: [], - }); - const [workingFilters, setWorkingFilters] = useState({ - age: [], - gender: [], - occupation: [], - location: [], - techSavviness: [], - ethnicity: [], - }); - - - // Auto-save state management - const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); - const [lastSavedData, setLastSavedData] = useState(null); - const [saveRetryCount, setSaveRetryCount] = useState(0); - const debouncedSaveTimerRef = useRef(null); - const isSavingRef = useRef(false); - const isLoadingDraftRef = useRef(false); - - // Handler for persona view details navigation - const handlePersonaViewDetails = (persona: Persona) => { - // Set navigation context with current focus group state - setPreviousRoute('/focus-groups', { - focusGroupId: draftFocusGroupId, - focusGroupTab: 'participants', - isNewFocusGroup: !draftToEdit, - focusGroupData: { - name: form.getValues('name'), - description: form.getValues('description'), - selectedParticipants: selectedParticipants, - discussionGuide: discussionGuide, - } - }); - - // Navigate to persona profile page - navigate(`/synthetic-users/${persona.id}`); - }; - - // Function to collect unique filter options from personas - const getFilterOptions = (personas: any[]) => { - const options = { - age: new Set(), - gender: new Set(), - occupation: new Set(), - location: new Set(), - techSavviness: new Set(), - ethnicity: new Set(), - }; - personas.forEach(persona => { - // Add each attribute to the corresponding set - if (persona.age) options.age.add(persona.age); - if (persona.gender) options.gender.add(persona.gender); - if (persona.occupation) options.occupation.add(persona.occupation); - if (persona.location) options.location.add(persona.location); - - // Map numeric ranges to descriptive labels - if (persona.techSavviness !== undefined) { - const techLevel = - persona.techSavviness < 30 ? 'Low (0-30)' : - persona.techSavviness < 70 ? 'Medium (31-70)' : - 'High (71-100)'; - options.techSavviness.add(techLevel); - } - - // Convert boolean values to string labels - - if (persona.ethnicity) options.ethnicity.add(persona.ethnicity); - }); + // Track if draft has been loaded + const draftLoadedRef = useRef(false); - // Convert sets to sorted arrays - return { - age: Array.from(options.age).sort(), - gender: Array.from(options.gender).sort(), - occupation: Array.from(options.occupation).sort(), - location: Array.from(options.location).sort(), - techSavviness: Array.from(options.techSavviness).sort((a, b) => { - const order = ['Low (0-30)', 'Medium (31-70)', 'High (71-100)']; - return order.indexOf(a) - order.indexOf(b); - }), - ethnicity: Array.from(options.ethnicity).sort(), - }; - }; - - // Get filtered filter options based on current selections - const getFilteredOptions = (currentCategory: keyof FilterState) => { - // Create a temporary filter state without the current category - const tempFilters = {...workingFilters}; - tempFilters[currentCategory] = []; // Clear the current category - - // Filter the personas using the temporary filters - const eligiblePersonas = personas.filter(persona => { - // For folder filtering (persona-centric storage) - let matchesFolder = true; - if (selectedFolder !== DEFAULT_FOLDER_ID) { - matchesFolder = false; - - // Check if persona belongs to selected folder using persona-centric storage - // Check persona.folder_ids array (new approach) - if (persona.folder_ids && Array.isArray(persona.folder_ids)) { - matchesFolder = persona.folder_ids.includes(selectedFolder); - } - - // Legacy support for single folder_id properties - if (!matchesFolder && (persona.folder_id === selectedFolder || persona.folderId === selectedFolder)) { - matchesFolder = true; - } - } - - if (!matchesFolder) return false; - - // Apply all filters except the one for the current category - return Object.entries(tempFilters).every(([category, values]) => { - if (values.length === 0) return true; // Skip empty filter arrays - - const cat = category as keyof FilterState; - - if (cat === 'techSavviness' && persona.techSavviness !== undefined) { - const techLevel = - persona.techSavviness < 30 ? 'Low (0-30)' : - persona.techSavviness < 70 ? 'Medium (31-70)' : - 'High (71-100)'; - return values.includes(techLevel); - } - else if (cat === 'age' && persona.age) { - return values.includes(persona.age); - } - else if (cat === 'gender' && persona.gender) { - return values.includes(persona.gender); - } - else if (cat === 'occupation' && persona.occupation) { - return values.includes(persona.occupation); - } - else if (cat === 'location' && persona.location) { - return values.includes(persona.location); - } - else if (cat === 'ethnicity' && persona.ethnicity) { - return values.includes(persona.ethnicity); - } - - return true; // No matching filter for this category - }); - }); - - // Now get the filter options from the filtered personas - return getFilterOptions(eligiblePersonas); - }; - - // Apply filters function - const applyFilters = () => { - setIsFilterOpen(false); - setTimeout(() => { - setActiveFilters({...workingFilters}); - }, 0); - }; - - // Reset working filters - const handleResetFilters = () => { - setWorkingFilters({ - age: [], - gender: [], - occupation: [], - location: [], - techSavviness: [], - ethnicity: [], - }); - }; - - // Toggle a filter value - const toggleFilter = (category: keyof FilterState, value: string) => { - setWorkingFilters(prev => { - const newFilters = {...prev}; - // If value is already selected, remove it - if (newFilters[category].includes(value)) { - newFilters[category] = newFilters[category].filter(v => v !== value); - } else { - // Otherwise add it - newFilters[category] = [...newFilters[category], value]; - } - return newFilters; - }); - }; - - // Function to fetch folders from database - const fetchFolders = async () => { - try { - const response = await foldersApi.getAll(); - const serverFolders = response.data; - - // Convert server folder format to match frontend expectations - const processedFolders: Folder[] = serverFolders.map((folder: any) => ({ - ...folder, - id: folder._id // Add legacy id field for compatibility - })); - - setFolders(processedFolders); - return processedFolders; - } catch (error) { - console.error("Error fetching folders:", error); - toast.error("Failed to load folders"); - setFolders([]); - return []; - } - }; - - // Folder management functions - const createNewFolder = async () => { - if (!newFolderName.trim()) { - toast.error("Please enter a folder name"); - return; - } - - try { - const response = await foldersApi.create({ - name: newFolderName.trim() - }); - - // Refresh folders from server - await fetchFolders(); - - setNewFolderName(''); - setIsCreatingFolder(false); - - toast.success(`Folder "${newFolderName}" created`); - } catch (error) { - console.error("Error creating folder:", error); - toast.error("Failed to create folder"); - } - }; - - const cancelFolderCreation = () => { - setNewFolderName(''); - setIsCreatingFolder(false); - }; - - const startRenameFolder = (folder: Folder) => { - setFolderToRename(folder); - setRenameFolderName(folder.name); - }; - - const completeRenameFolder = async () => { - if (!folderToRename || !renameFolderName.trim()) { - setFolderToRename(null); - return; - } - - try { - await foldersApi.update(folderToRename._id, { - name: renameFolderName.trim() - }); - - // Refresh folders from server - await fetchFolders(); - - setFolderToRename(null); - toast.success(`Folder renamed to "${renameFolderName}"`); - } catch (error) { - console.error("Error renaming folder:", error); - toast.error("Failed to rename folder"); - setFolderToRename(null); - } - }; - - const cancelRenameFolder = () => { - setFolderToRename(null); - setRenameFolderName(''); - }; - - // Note: Synchronization is no longer needed with persona-centric storage - // Folder membership is stored in persona.folder_ids array server-side - - // Fetch personas and folders from the database when component mounts - useEffect(() => { - const fetchPersonas = async () => { - setIsLoadingPersonas(true); - try { - const response = await personasApi.getAll(); - console.log("Fetched personas for FocusGroupModerator:", response.data); - - if (Array.isArray(response.data) && response.data.length > 0) { - setPersonas(response.data); - } else { - console.warn("No personas returned from API or invalid format", response.data); - toast.warning("No participants available"); - } - } catch (error) { - console.error("Error fetching personas:", error); - toast.error("Failed to load participants"); - } finally { - setIsLoadingPersonas(false); - } - }; - - const loadData = async () => { - // Load both folders and personas from database - await Promise.all([ - fetchFolders(), - fetchPersonas() - ]); - }; - - loadData(); - }, []); - - // Note: Folder management is now handled server-side with persona-centric storage - // No localStorage saving or manual synchronization needed - - console.log('About to initialize form with useForm hook'); + // Initialize form const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -533,329 +88,106 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele verbosity: "medium", }, }); - console.log('Form initialized successfully'); - // Simplified auto-save trigger function - only debounced save on changes - const triggerAutoSave = () => { - if (activeTab !== 'setup' || isLoadingDraftRef.current) return; - - // Clear existing debounced timer - if (debouncedSaveTimerRef.current) { - clearTimeout(debouncedSaveTimerRef.current); - } - - // Schedule debounced save - create inline function to avoid dependencies - debouncedSaveTimerRef.current = setTimeout(async () => { - if (isSavingRef.current) return; - - const values = form.getValues(); - const currentData = { - name: values.focusGroupName || '', - description: values.researchBrief || '', - objective: values.researchBrief || '', - topic: values.discussionTopics || '', - duration: values.duration ? parseInt(values.duration) : 60, - llm_model: values.llm_model || 'gemini-3-pro-preview', - reasoning_effort: values.reasoning_effort || 'medium', - verbosity: values.verbosity || 'medium', - participants: selectedParticipants, - participants_count: selectedParticipants.length, - status: 'draft', - date: new Date().toISOString(), - uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown') - }; - - if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) { - return; // No changes - } + // Custom hooks + const folderManagement = useFolderManagement(); - if (!currentData.name && !currentData.description && !currentData.topic) { - return; // Don't save empty form - } + const filtering = usePersonaFiltering({ + personas, + selectedFolder: folderManagement.selectedFolder, + }); - isSavingRef.current = true; - setAutoSaveStatus('saving'); + const guideGeneration = useDiscussionGuideGeneration({ + form, + }); + const autoSave = useFocusGroupAutoSave({ + form, + selectedParticipants, + backendAssets, + draftFocusGroupId, + draftToEdit, + activeTab, + onDraftIdChange: setDraftFocusGroupId, + }); + + // Fetch personas on mount + useEffect(() => { + const fetchPersonas = async () => { + setIsLoadingPersonas(true); try { - // Use draftFocusGroupId from state, or fall back to draftToEdit ID if available - let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id); - console.log("Auto-save: draftFocusGroupId =", draftFocusGroupId); - console.log("Auto-save: draftToEdit ID =", draftToEdit?.id || draftToEdit?._id); - console.log("Auto-save: using focusGroupId =", focusGroupId); - console.log("Auto-save: llm_model in currentData =", currentData.llm_model); - console.log("Auto-save: duration in currentData =", currentData.duration); - - if (!focusGroupId) { - console.log("Auto-save: Creating NEW focus group (no existing ID)"); - const response = await focusGroupsApi.create(currentData); - focusGroupId = response.data.focus_group_id || response.data.id || response.data._id; - setDraftFocusGroupId(focusGroupId); - console.log("Auto-save: Created new draft with ID:", focusGroupId); + const response = await personasApi.getAll(); + if (Array.isArray(response.data) && response.data.length > 0) { + setPersonas(response.data); } else { - console.log("Auto-save: Updating existing focus group:", focusGroupId); - await focusGroupsApi.update(focusGroupId, currentData); - console.log("Auto-save: Updated existing draft:", focusGroupId); + toast.warning("No participants available"); } - - setLastSavedData(currentData); - setAutoSaveStatus('saved'); - setSaveRetryCount(0); - - setTimeout(() => { - setAutoSaveStatus('idle'); - }, 2000); - } catch (error) { - console.error("Auto-save failed:", error); - setAutoSaveStatus('error'); - setSaveRetryCount(prev => prev + 1); - - if (saveRetryCount < 3) { - const retryDelay = Math.pow(2, saveRetryCount) * 2000; - setTimeout(() => { - triggerAutoSave(); - }, retryDelay); - } else { - toast.error("Auto-save failed", { - description: "Your changes may not be saved. Please check your connection.", - }); - } + console.error("Error fetching personas:", error); + toast.error("Failed to load participants"); } finally { - isSavingRef.current = false; + setIsLoadingPersonas(false); } - }, 2000); - }; + }; - // Function to fetch backend assets - const fetchBackendAssets = async (focusGroupId: string) => { + fetchPersonas(); + }, []); + + // Fetch backend assets + const fetchBackendAssets = useCallback(async (focusGroupId: string) => { try { - setIsLoadingAssets(true); const response = await focusGroupsApi.getAssets(focusGroupId); setBackendAssets(response.data.assets || []); } catch (error) { console.error("Error fetching backend assets:", error); toast.error("Failed to load asset information"); - } finally { - setIsLoadingAssets(false); } - }; + }, []); - // Function to fetch available focus groups with discussion guides for copying - const fetchAvailableFocusGroups = async () => { - console.log("fetchAvailableFocusGroups called"); - try { - setIsLoadingFocusGroups(true); - const response = await focusGroupsApi.getAll(); - console.log("Fetched focus groups:", response.data); - - // Filter to only include focus groups that have discussion guides - const focusGroupsWithGuides = response.data.filter((fg: any) => - fg.discussionGuide && - fg.discussionGuide !== null && - fg.discussionGuide !== '' && - // Exclude the current focus group being edited (if any) - fg._id !== draftFocusGroupId - ); - - console.log("Focus groups with guides:", focusGroupsWithGuides); - setAvailableFocusGroups(focusGroupsWithGuides); - } catch (error) { - console.error("Error fetching focus groups:", error); - toast.error("Failed to load available focus groups"); - } finally { - setIsLoadingFocusGroups(false); - } - }; - - // Function to copy discussion guide from selected focus group - const handleCopyDiscussionGuide = async (sourceFocusGroupId: string) => { - try { - // Find the selected focus group - const sourceFocusGroup = availableFocusGroups.find(fg => fg._id === sourceFocusGroupId); - if (!sourceFocusGroup || !sourceFocusGroup.discussionGuide) { - toast.error("Selected focus group does not have a discussion guide"); - return; - } - - // Set the discussion guide state with the copied guide - setDiscussionGuide(sourceFocusGroup.discussionGuide); - - // If we have a draft focus group ID, update it with the copied discussion guide - if (draftFocusGroupId) { - try { - const values = form.getValues(); - const updateData = { - name: values.focusGroupName, - status: 'draft', - participants: selectedParticipants, - participants_count: selectedParticipants.length, - duration: parseInt(values.duration || '60'), - topic: values.discussionTopics || '', - description: values.researchBrief || '', - objective: values.researchBrief || '', - llm_model: values.llm_model || 'gemini-3-pro-preview', - reasoning_effort: values.reasoning_effort || 'medium', - verbosity: values.verbosity || 'medium', - discussionGuide: sourceFocusGroup.discussionGuide - }; - - await focusGroupsApi.update(draftFocusGroupId, updateData); - console.log("Draft focus group updated with copied discussion guide"); - - } catch (error) { - console.error("Failed to update focus group with copied discussion guide:", error); - toast.error("Failed to save copied discussion guide", { - description: "Discussion guide copied, but draft save failed", - }); - } - } - - // Close the modal - setIsCopyGuideModalOpen(false); - - // Switch to review tab - setActiveTab('review'); - - // Show success toast - toast.success("Discussion guide copied successfully", { - description: `Copied from "${sourceFocusGroup.name}"`, - }); - - } catch (error) { - console.error("Error copying discussion guide:", error); - toast.error("Failed to copy discussion guide", { - description: "An error occurred while copying the discussion guide", - }); - } - }; - - // Function to update asset name - const updateAssetName = async (focusGroupId: string, filename: string, newName: string) => { - try { - await focusGroupsApi.updateAssetName(focusGroupId, filename, newName); - - // Update local state - setBackendAssets(prev => prev.map(asset => - asset.filename === filename - ? { ...asset, user_assigned_name: newName } - : asset - )); - - toast.success("Asset name updated"); - } catch (error) { - console.error("Error updating asset name:", error); - toast.error("Failed to update asset name"); - } - }; - - // Watch for form field changes to trigger auto-save - const watchedFields = form.watch(); - - // Use refs to track previous values to prevent unnecessary saves - const prevWatchedFieldsRef = useRef(''); - const prevSelectedParticipantsRef = useRef(''); - - // Effect to handle form field changes and trigger auto-save + // Load draft data when editing useEffect(() => { - const currentWatchedFields = JSON.stringify(watchedFields); - if (activeTab === 'setup' && currentWatchedFields !== prevWatchedFieldsRef.current) { - prevWatchedFieldsRef.current = currentWatchedFields; - triggerAutoSave(); - } - }, [watchedFields, activeTab]); - - // Effect to handle participant changes - useEffect(() => { - const currentParticipants = JSON.stringify(selectedParticipants); - if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) { - prevSelectedParticipantsRef.current = currentParticipants; - triggerAutoSave(); - } - }, [selectedParticipants, activeTab]); - - // Asset uploads are now handled immediately via AssetUploader component - - // Effect to clear timers when leaving setup tab or component unmounts - useEffect(() => { - if (activeTab !== 'setup') { - // Clear debounced timer when leaving setup tab - if (debouncedSaveTimerRef.current) { - clearTimeout(debouncedSaveTimerRef.current); - } - } - - // Cleanup timer on unmount - return () => { - if (debouncedSaveTimerRef.current) { - clearTimeout(debouncedSaveTimerRef.current); - } - }; - }, [activeTab]); - - // Effect to load draft data when editing an existing draft - useEffect(() => { - console.log("Draft loading effect - draftToEdit:", draftToEdit, "draftLoadedRef.current:", draftLoadedRef.current); - - // Reset loaded flag when draftToEdit changes if (!draftToEdit) { draftLoadedRef.current = false; return; } - + if (draftToEdit && !draftLoadedRef.current) { - console.log("Loading draft focus group:", draftToEdit); - isLoadingDraftRef.current = true; // Prevent auto-save during loading - draftLoadedRef.current = true; // Mark as loaded to prevent re-loading - - // Set the draft ID + autoSave.setIsLoadingDraft(true); + draftLoadedRef.current = true; + const draftId = draftToEdit.id || draftToEdit._id; setDraftFocusGroupId(draftId); - console.log("Setting draft ID from draftToEdit:", draftId); - - // Load backend assets for this focus group + if (draftId) { fetchBackendAssets(draftId); } - - // Load form data if available - if (draftToEdit.name) { - form.setValue('focusGroupName', draftToEdit.name); - } + + // Load form data + if (draftToEdit.name) form.setValue('focusGroupName', draftToEdit.name); if (draftToEdit.description || draftToEdit.objective) { form.setValue('researchBrief', draftToEdit.description || draftToEdit.objective || ''); } - if (draftToEdit.topic) { - form.setValue('discussionTopics', draftToEdit.topic); - } - if (draftToEdit.duration) { - form.setValue('duration', draftToEdit.duration.toString()); - } - if (draftToEdit.llm_model) { - form.setValue('llm_model', draftToEdit.llm_model); - } - if (draftToEdit.reasoning_effort) { - form.setValue('reasoning_effort', draftToEdit.reasoning_effort); - } - if (draftToEdit.verbosity) { - form.setValue('verbosity', draftToEdit.verbosity); - } - - // Load discussion guide if available + if (draftToEdit.topic) form.setValue('discussionTopics', draftToEdit.topic); + if (draftToEdit.duration) form.setValue('duration', draftToEdit.duration.toString()); + if (draftToEdit.llm_model) form.setValue('llm_model', draftToEdit.llm_model); + if (draftToEdit.reasoning_effort) form.setValue('reasoning_effort', draftToEdit.reasoning_effort); + if (draftToEdit.verbosity) form.setValue('verbosity', draftToEdit.verbosity); + + // Load discussion guide if (draftToEdit.discussionGuide) { - setDiscussionGuide(draftToEdit.discussionGuide); - // If we have a discussion guide and no navigation state override, start on the review tab + guideGeneration.setDiscussionGuide(draftToEdit.discussionGuide); if (!navigationState.focusGroupTab || navigationState.previousRoute !== '/focus-groups') { setActiveTab('review'); } } - - // Load selected participants if available + + // Load participants if (draftToEdit.participants && Array.isArray(draftToEdit.participants)) { setSelectedParticipants(draftToEdit.participants); } - - // Set lastSavedData to current draft state to prevent immediate auto-save - const currentDraftData = { + + // Set last saved data + autoSave.setLastSavedData({ name: draftToEdit.name || '', description: draftToEdit.description || draftToEdit.objective || '', objective: draftToEdit.description || draftToEdit.objective || '', @@ -869,174 +201,83 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele status: 'draft', date: draftToEdit.date || new Date().toISOString(), uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown') - }; - setLastSavedData(currentDraftData); - console.log("Set lastSavedData to current draft:", currentDraftData); + }); toast.success("Draft focus group loaded", { description: "Continue editing your focus group setup" }); - - // Allow auto-save after loading is complete - setTimeout(() => { - isLoadingDraftRef.current = false; - // Ensure initial form state is captured after loading - const initialFormState = JSON.stringify(form.getValues()); - prevWatchedFieldsRef.current = initialFormState; - }, 1000); // Give it a second to settle - } - }, [draftToEdit, form]); - // Effect to handle pre-selected participants from persona list + setTimeout(() => { + autoSave.setIsLoadingDraft(false); + }, 1000); + } + }, [draftToEdit, form, fetchBackendAssets, navigationState, autoSave, guideGeneration, backendAssets]); + + // Handle pre-selected participants useEffect(() => { if (preSelectedParticipants.length > 0) { - console.log("Pre-selected participants received:", preSelectedParticipants); setSelectedParticipants(preSelectedParticipants); - // Auto-switch to participants tab to show the pre-selected personas setActiveTab('participants'); } }, [preSelectedParticipants]); - // Handle navigation state to set the correct tab when returning from persona details + // Handle navigation state for returning from persona details useEffect(() => { if (navigationState.focusGroupTab && navigationState.previousRoute === '/focus-groups') { - // Use setTimeout to ensure this runs after other tab-setting logic setTimeout(() => { setActiveTab(navigationState.focusGroupTab); - // Clear navigation state after using it clearNavigationState(); }, 0); } - }, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]); // Also depend on draftToEdit so this runs after draft loading + }, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]); - // Initialize refs on mount for new focus groups (not editing drafts) + // Revert to setup tab when generation is cancelled useEffect(() => { - if (!draftToEdit) { - setTimeout(() => { - isLoadingDraftRef.current = false; - // Ensure initial form state is captured for new focus groups - const initialFormState = JSON.stringify(form.getValues()); - prevWatchedFieldsRef.current = initialFormState; - }, 500); // Allow initial render to complete - } - }, [draftToEdit, form]); - - - // Save Status Indicator Component - const SaveStatusIndicator = () => { - if (autoSaveStatus === 'idle') return null; - - const statusConfig = { - saving: { text: 'Saving...', className: 'text-blue-600 bg-blue-50' }, - saved: { text: 'All changes saved', className: 'text-green-600 bg-green-50' }, - error: { text: 'Save failed - retrying...', className: 'text-red-600 bg-red-50' } - }; - - const config = statusConfig[autoSaveStatus]; - - return ( -
- {config.text} -
- ); - }; - - // Function to generate a discussion guide via the API - const generateDiscussionGuide = async (values: z.infer, focusGroupId?: string): Promise => { - // Start cancellable generation and open progress modal - guideGenerationControls.startGeneration(); - setIsGuideProgressModalOpen(true); - - try { - // Prepare data for API request - const requestData = { - name: values.focusGroupName, - description: values.researchBrief, - objective: values.researchBrief, - topic: values.discussionTopics, - duration: parseInt(values.duration), - llm_model: values.llm_model, - reasoning_effort: values.reasoning_effort, - verbosity: values.verbosity - }; - - // Call API to generate discussion guide, with focus group ID if available - const response = focusGroupId - ? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData) - : await focusGroupsApi.generateDiscussionGuide(requestData); - - // Set task ID if available - if (response.data?.task_id) { - guideGenerationControls.setTaskId(response.data.task_id); - } - - // Check if we got a successful response with a discussion guide - if (response.data && response.data.discussionGuide) { - guideGenerationControls.completeGeneration(); - return response.data.discussionGuide; - } else { - throw new Error("Failed to generate discussion guide"); - } - } catch (error) { - // Check if this was a cancellation - if (error.response?.status === 499) { - return ''; - } - - console.error("Error generating discussion guide:", error); - guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide'); - - // Extract error message from axios error response - let errorMessage = 'Unknown error occurred'; - if (error?.response?.data?.error) { - errorMessage = error.response.data.error; - } else if (error?.message) { - errorMessage = error.message; - } - - if (errorMessage.includes('500') || errorMessage.includes('internal error') || errorMessage.includes('Internal Server Error')) { - toast.error("AI service temporarily unavailable", { - description: "The discussion guide generator is experiencing issues. Please try again in a few minutes.", - action: { - label: "Retry", - onClick: () => generateDiscussionGuide(values) - } - }); - } else { - toast.error("Failed to generate discussion guide", { - description: errorMessage, - action: { - label: "Retry", - onClick: () => generateDiscussionGuide(values) - } - }); - } - - // Don't provide fallback template - throw the error to prevent showing dummy guide - throw error; - } - // Note: Don't set isGenerating to false here - let the progress bar handle it - }; - - const handleGuideProgressComplete = () => { - setIsGuideProgressModalOpen(false); - guideGenerationControls.resetGeneration(); - }; - - // Switch to Setup tab when discussion guide generation is cancelled (not completed successfully) - useEffect(() => { - if (!guideGenerationState.isGenerating && !guideGenerationState.isCancelling && - guideGenerationState.taskId === null && activeTab === 'review' && !discussionGuide) { - // Only revert to setup if no guide was generated (i.e., cancellation, not success) + if (!guideGeneration.guideGenerationState.isGenerating && + !guideGeneration.guideGenerationState.isCancelling && + guideGeneration.guideGenerationState.taskId === null && + activeTab === 'review' && + !guideGeneration.discussionGuide) { setActiveTab('setup'); } - }, [guideGenerationState.isGenerating, guideGenerationState.isCancelling, guideGenerationState.taskId, activeTab, discussionGuide]); + }, [guideGeneration.guideGenerationState, activeTab, guideGeneration.discussionGuide]); + + // Handle participant selection + const handleParticipantSelection = useCallback((id: string) => { + setSelectedParticipants(prev => { + const isSelected = prev.includes(id); + return isSelected ? prev.filter(pId => pId !== id) : [...prev, id]; + }); + }, []); + + // Handle persona view details + const handlePersonaViewDetails = useCallback((persona: Persona) => { + setPreviousRoute('/focus-groups', { + focusGroupId: draftFocusGroupId, + focusGroupTab: 'participants', + isNewFocusGroup: !draftToEdit, + focusGroupData: { + name: form.getValues('focusGroupName'), + description: form.getValues('researchBrief'), + selectedParticipants: selectedParticipants, + discussionGuide: guideGeneration.discussionGuide, + } + }); + navigate(`/synthetic-users/${persona.id}`); + }, [setPreviousRoute, draftFocusGroupId, draftToEdit, form, selectedParticipants, guideGeneration.discussionGuide, navigate]); + + // Handle form submission (generate discussion guide) + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + + const isValid = await form.trigger(); + if (!isValid) return; + + const values = form.getValues(); - async function onSubmit(values: z.infer) { try { - // Use existing focus group ID or create new draft for discussion guide generation let focusGroupId = draftFocusGroupId; - + if (!focusGroupId) { const draftData = { name: values.focusGroupName, @@ -1052,350 +293,142 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele reasoning_effort: values.reasoning_effort, verbosity: values.verbosity, }; - + const savedDraft = await focusGroupsApi.create(draftData); focusGroupId = savedDraft.data.focus_group_id || savedDraft.data.id || savedDraft.data._id; setDraftFocusGroupId(focusGroupId); - console.log("Draft focus group created for discussion guide generation:", savedDraft, "with ID:", focusGroupId); } - - // Assets are now uploaded immediately via AssetUploader component - // No need to handle asset uploads here - - // Update focus group with current form values before generating guide - // This ensures the backend uses the latest model selection + + // Update focus group before generating if (focusGroupId) { - try { - const preUpdateData = { - name: values.focusGroupName, - participants: selectedParticipants, - participants_count: selectedParticipants.length, - duration: parseInt(values.duration), - topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), - description: values.researchBrief, - objective: values.researchBrief, - llm_model: values.llm_model, - reasoning_effort: values.reasoning_effort, - verbosity: values.verbosity - }; - - await focusGroupsApi.update(focusGroupId, preUpdateData); - console.log("Focus group updated with latest form values before guide generation"); - console.log(`🔄 Updated focus group ${focusGroupId} with model: ${values.llm_model}`); - } catch (error) { - console.error("Failed to update focus group before guide generation:", error); - // Continue anyway, as the generateDiscussionGuide will use form values as fallback - } + const preUpdateData = { + name: values.focusGroupName, + participants: selectedParticipants, + participants_count: selectedParticipants.length, + duration: parseInt(values.duration), + topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), + description: values.researchBrief, + objective: values.researchBrief, + llm_model: values.llm_model, + reasoning_effort: values.reasoning_effort, + verbosity: values.verbosity + }; + await focusGroupsApi.update(focusGroupId, preUpdateData); } - - try { - // Generate discussion guide based on form input (after database is updated) - const guide = await generateDiscussionGuide(values, focusGroupId); - - // Check if generation was cancelled (returns empty string or object) - if (!guide || (typeof guide === 'string' && guide.trim() === '')) { - console.log('Discussion guide generation was cancelled'); - return; // Exit early, don't process or show success toasts - } - - setDiscussionGuide(guide); - - // Update the focus group with the discussion guide - try { - const updateData = { - name: values.focusGroupName, - status: 'draft', - participants: selectedParticipants, - participants_count: selectedParticipants.length, - date: new Date().toISOString(), - duration: parseInt(values.duration), - topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), - description: values.researchBrief, - objective: values.researchBrief, - llm_model: values.llm_model, - reasoning_effort: values.reasoning_effort, - verbosity: values.verbosity, - discussionGuide: guide - }; - - await focusGroupsApi.update(focusGroupId, updateData); - console.log("Focus group updated with discussion guide"); - - toast.success("Progress saved as draft", { - description: "Your focus group setup has been automatically saved", - }); - } catch (error) { - console.error("Failed to update focus group with discussion guide:", error); - toast.error("Failed to save draft", { - description: "Discussion guide generated, but draft save failed", - }); - } - - // Move to review tab after successful generation - setActiveTab('review'); - - toast.success("Discussion guide generated", { - description: "Review and edit before proceeding", - }); - - } catch (guideError) { - console.error("Discussion guide generation failed:", guideError); - // Don't set discussion guide or move to review tab - // Show error message with instruction to go back to setup tab and try again - toast.error("Discussion guide generation failed", { - description: "Please go back to the setup tab and try generating again. Check your inputs and try a different AI model if the issue persists.", - duration: 8000, // Show longer so user can read the instruction - }); - // Stay on current tab (setup) so user can try again - return; + + // Generate discussion guide + const guide = await guideGeneration.generateDiscussionGuide(values, focusGroupId); + + if (!guide || (typeof guide === 'string' && guide.trim() === '')) { + return; // Cancelled } - - } catch (error) { + + guideGeneration.setDiscussionGuide(guide); + + // Save with discussion guide + if (focusGroupId) { + const updateData = { + name: values.focusGroupName, + status: 'draft', + participants: selectedParticipants, + participants_count: selectedParticipants.length, + date: new Date().toISOString(), + duration: parseInt(values.duration), + topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), + description: values.researchBrief, + objective: values.researchBrief, + llm_model: values.llm_model, + reasoning_effort: values.reasoning_effort, + verbosity: values.verbosity, + discussionGuide: guide + }; + + await focusGroupsApi.update(focusGroupId, updateData); + toast.success("Progress saved as draft"); + } + + setActiveTab('review'); + toast.success("Discussion guide generated", { + description: "Review and edit before proceeding", + }); + + } catch (error: any) { console.error("Error in focus group creation flow:", error); - toast.error("Focus group creation failed", { - description: error.message || "An unexpected error occurred", + toast.error("Discussion guide generation failed", { + description: "Please go back to the setup tab and try generating again.", + duration: 8000, }); } - } + }, [draftFocusGroupId, form, selectedParticipants, guideGeneration]); - // Filtered personas based on search term, folder selection, and active filters - const filteredPersonas = (() => { - const filtered = personas.filter(persona => { - // Text search matching - const matchesSearch = ( - persona.name.toLowerCase().includes(searchTerm.toLowerCase()) || - (persona.occupation && persona.occupation.toLowerCase().includes(searchTerm.toLowerCase())) || - (persona.location && persona.location.toLowerCase().includes(searchTerm.toLowerCase())) - ); - - // Apply additional filter criteria - const matchesFilters = - // Match age filter - (activeFilters.age.length === 0 || activeFilters.age.includes(persona.age)) && - // Match gender filter - (activeFilters.gender.length === 0 || activeFilters.gender.includes(persona.gender)) && - // Match occupation filter - (activeFilters.occupation.length === 0 || activeFilters.occupation.includes(persona.occupation)) && - // Match location filter - (activeFilters.location.length === 0 || activeFilters.location.includes(persona.location)) && - // Match ethnicity filter - (activeFilters.ethnicity.length === 0 || - (persona.ethnicity && activeFilters.ethnicity.includes(persona.ethnicity))) && - // Match tech savviness filter (convert numeric value to text bucket) - (activeFilters.techSavviness.length === 0 || - (persona.techSavviness !== undefined && activeFilters.techSavviness.includes( - persona.techSavviness < 30 ? 'Low (0-30)' : - persona.techSavviness < 70 ? 'Medium (31-70)' : - 'High (71-100)' - ))) && -true; - - // Folder filtering (persona-centric storage) - let matchesFolder = true; - - // Only filter by folder if not the default "All Personas" folder - if (selectedFolder !== DEFAULT_FOLDER_ID) { - matchesFolder = false; // Start with false and set to true if it matches - - // Check if persona belongs to selected folder using persona-centric storage - // Primary approach: Check persona.folder_ids array - if (persona.folder_ids && Array.isArray(persona.folder_ids)) { - matchesFolder = persona.folder_ids.includes(selectedFolder); - } - - // Legacy support: Check single folder ID properties - if (!matchesFolder) { - if (persona.folder_id === selectedFolder || persona.folderId === selectedFolder) { - matchesFolder = true; - } - } - } - - return matchesSearch && matchesFilters && matchesFolder; - }); - - // Debug log filtered results - console.log(`Filtered personas: ${filtered.length}/${personas.length}`); - console.log(`Selected folder: ${selectedFolder === DEFAULT_FOLDER_ID ? 'All Personas' : - folders.find(f => f._id === selectedFolder || f.id === selectedFolder)?.name || selectedFolder}`); - - if (selectedFolder !== DEFAULT_FOLDER_ID) { - const folder = folders.find(f => f._id === selectedFolder || f.id === selectedFolder); - if (folder) { - // Count personas that belong to this folder (persona-centric approach) - const personasInFolder = personas.filter(p => { - // Check folder_ids array - if (p.folder_ids && Array.isArray(p.folder_ids)) { - return p.folder_ids.includes(selectedFolder); - } - // Check legacy single folder properties - return p.folder_id === selectedFolder || p.folderId === selectedFolder; - }); - - console.log(`Folder details: ${folder.name}, ID: ${folder._id}, Contains: ${personasInFolder.length} personas`); - console.log(`Personas in this folder:`, personasInFolder.map(p => p.name)); - } - } - - return filtered; - })(); - - const handleParticipantSelection = (id: string) => { - console.log("Toggling selection for participant ID:", id); - - setSelectedParticipants(prev => { - const isSelected = prev.includes(id); - - console.log("Current selection:", { - id, - isCurrentlySelected: isSelected, - currentSelections: [...prev] - }); - - // Toggle selection - const newSelection = isSelected - ? prev.filter(pId => pId !== id) - : [...prev, id]; - - console.log("New selection:", newSelection); - - // Auto-save will be triggered by the useEffect watching selectedParticipants - - return newSelection; - }); - }; - - - // Asset upload is now handled by AssetUploader component - // This function is no longer needed - - // Function to save the focus group to the database - const saveFocusGroup = async () => { - try { - // Get form values - const values = form.getValues(); - - const focusGroupData = { - name: values.focusGroupName, - status: 'in-progress', - participants: selectedParticipants, - participants_count: selectedParticipants.length, - date: new Date().toISOString(), - duration: parseInt(values.duration), - topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), - discussionGuide: discussionGuide - }; - - // Use the API from lib/api.ts to save the focus group - const response = await focusGroupsApi.create(focusGroupData); - - const result = response.data; - console.log("Focus group created successfully:", result); - return result.focus_group_id; - } catch (error) { - console.error('Error saving focus group:', error); - throw error; - } - }; - - // Function to download discussion guide as markdown - const handleDownloadDiscussionGuide = useCallback(async () => { - if (!discussionGuideRef.current) { - toast.error("No discussion guide available", { - description: "Please generate a discussion guide first" - }); + // Handle copying discussion guide from another focus group + const handleCopyDiscussionGuide = useCallback(async (sourceFocusGroup: any) => { + if (!sourceFocusGroup || !sourceFocusGroup.discussionGuide) { + toast.error("Selected focus group does not have a discussion guide"); return; } - setIsDownloadingGuide(true); - - try { - // Use the client-side download utility - const { downloadDiscussionGuideAsMarkdown } = await import('@/utils/discussionGuideMarkdown'); - const formValues = form.getValues(); - - downloadDiscussionGuideAsMarkdown(discussionGuideRef.current, formValues.focusGroupName); - - toast.success("Discussion guide downloaded", { - description: "The guide has been saved to your downloads folder" - }); - } catch (error) { - console.error('Error downloading discussion guide:', error); - toast.error("Download failed", { - description: "Unable to download the discussion guide. Please try again." - }); - } finally { - setIsDownloadingGuide(false); + guideGeneration.setDiscussionGuide(sourceFocusGroup.discussionGuide); + + // Update draft with copied guide + if (draftFocusGroupId) { + try { + const values = form.getValues(); + const updateData = { + name: values.focusGroupName, + status: 'draft', + participants: selectedParticipants, + participants_count: selectedParticipants.length, + duration: parseInt(values.duration || '60'), + topic: values.discussionTopics || '', + description: values.researchBrief || '', + objective: values.researchBrief || '', + llm_model: values.llm_model || 'gemini-3-pro-preview', + reasoning_effort: values.reasoning_effort || 'medium', + verbosity: values.verbosity || 'medium', + discussionGuide: sourceFocusGroup.discussionGuide + }; + + await focusGroupsApi.update(draftFocusGroupId, updateData); + } catch (error) { + console.error("Failed to update focus group with copied discussion guide:", error); + toast.error("Failed to save copied discussion guide"); + } } - }, [form]); // Only depend on form, use ref for discussionGuide - // Stable callback for saving discussion guide changes - const handleSaveDiscussionGuide = useCallback(async (updatedGuide: any) => { - console.log('📝 handleSaveDiscussionGuide called with:', updatedGuide); - // Only update the discussion guide state if we're not currently editing - // This prevents re-renders that would cause focus loss during editing - if (!isEditingGuide) { - setDiscussionGuide(updatedGuide); - toast.success('Discussion guide updated', { - description: 'Your changes have been saved.' - }); - } else { - // During editing, just update the ref so the latest version is available - discussionGuideRef.current = updatedGuide; - console.log('📝 Skipping discussionGuide state update during editing to preserve focus'); - } - }, [isEditingGuide]); + setIsCopyGuideModalOpen(false); + setActiveTab('review'); + toast.success("Discussion guide copied successfully", { + description: `Copied from "${sourceFocusGroup.name}"`, + }); + }, [draftFocusGroupId, form, selectedParticipants, guideGeneration]); - // Handle editing state changes from DiscussionGuideViewer - const handleEditingStateChange = useCallback((editing: boolean) => { - console.log('📝 Discussion guide editing state changed:', editing); - setIsEditingGuide(editing); - - // When editing ends, update the discussion guide state with the latest version - if (!editing && discussionGuideRef.current) { - console.log('📝 Updating discussionGuide state after editing ended'); - setDiscussionGuide(discussionGuideRef.current); - } - }, []); - - // Stable dummy callbacks for optional props - const handleSectionSelect = useCallback(() => {}, []); - - const handleStartFocusGroup = async () => { - // Validate form data + // Handle starting focus group + const handleStartFocusGroup = useCallback(async () => { if (!form.getValues().focusGroupName) { - toast.error("Missing focus group name", { - description: "Please provide a name for the focus group", - }); + toast.error("Missing focus group name"); return; } - - if (!discussionGuide) { - toast.error("Missing discussion guide", { - description: "Please generate a discussion guide first", - }); + + if (!guideGeneration.discussionGuide) { + toast.error("Missing discussion guide"); return; } - + if (selectedParticipants.length < 1) { toast.error("Not enough participants", { - description: "Please select at least one participant for the focus group", + description: "Please select at least one participant", }); return; } - - console.log("Starting focus group with participants:", selectedParticipants); - + try { - // Show loading toast toast.loading("Creating focus group..."); - - // Save the focus group to the database first - let focusGroupId; - if (draftFocusGroupId) { - // Update existing draft to in-progress status - const values = form.getValues(); - const updatedFocusGroupData = { + const values = form.getValues(); + let focusGroupId = draftFocusGroupId; + + if (focusGroupId) { + const updatedData = { name: values.focusGroupName, status: 'in-progress', participants: selectedParticipants, @@ -1405,992 +438,163 @@ true; topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, - discussionGuide: discussionGuide + discussionGuide: guideGeneration.discussionGuide }; - - const response = await focusGroupsApi.update(draftFocusGroupId, updatedFocusGroupData); - focusGroupId = draftFocusGroupId; - console.log("Draft focus group updated to in-progress:", response); - - // Notify parent component that draft was saved - if (onDraftSaved) { - onDraftSaved(); - } + + await focusGroupsApi.update(focusGroupId, updatedData); + if (onDraftSaved) onDraftSaved(); } else { - // Create new focus group - focusGroupId = await saveFocusGroup(); + const focusGroupData = { + name: values.focusGroupName, + status: 'in-progress', + participants: selectedParticipants, + participants_count: selectedParticipants.length, + date: new Date().toISOString(), + duration: parseInt(values.duration), + topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), + discussionGuide: guideGeneration.discussionGuide + }; + + const response = await focusGroupsApi.create(focusGroupData); + focusGroupId = response.data.focus_group_id; } - - // Dismiss loading toast and show success + toast.dismiss(); - toast.success("Focus group created successfully", { - description: "The AI moderator is now running the session", - }); - - // Navigate to the focus group session + toast.success("Focus group created successfully"); navigate(`/focus-groups/${focusGroupId}`); - } catch (error: any) { - // Dismiss loading toast + } catch (error) { toast.dismiss(); - - // Show specific error message if available - const errorMessage = error?.message || "Unknown error"; console.error("Failed to start focus group:", error); - - toast.error("Failed to create focus group", { - description: "Please try again or check your connection", - }); + toast.error("Failed to create focus group"); } - }; + }, [form, guideGeneration.discussionGuide, selectedParticipants, draftFocusGroupId, onDraftSaved, navigate]); return ( <> - {/* Auto-save Status Indicator */} - - + +

AI Focus Group Moderator

- - {/* Progress Modal for Discussion Guide Generation */} - setIsGuideProgressModalOpen(false)} - isActive={guideGenerationState.isGenerating} - isComplete={guideGenerationState.isComplete} - hasError={guideGenerationState.hasError} - isCancelling={guideGenerationState.isCancelling} - taskId={guideGenerationState.taskId} - title="Generating Discussion Guide" - description="Creating your discussion guide based on the research objectives. This typically takes 30-60 seconds." - onCancel={guideGenerationControls.cancelGeneration} - onComplete={handleGuideProgressComplete} - /> - - - - Setup - Review & Edit - Participants - - - -
- - ( - - Focus Group Name - - - - - Give your focus group a descriptive name - - - - )} - /> - -
- ( - - Research Brief - -