- Created new reusable ProgressModal component with animated progress bar - Converted all inline GenerationProgressBar usages to modal dialogs: - AIRecruiter.tsx: Persona generation - FocusGroupModerator.tsx: Discussion guide generation - FocusGroupSession.tsx: Key themes extraction - SyntheticUsers.tsx: Persona summary generation - PersonaModificationModal.tsx: Persona modification - Modal features: auto-dismiss after completion, non-dismissible during operation, cancel support, progress animation from 0-90% over 54 seconds - Fixed broken theme generation state calls in FocusGroupSession.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1994 lines
83 KiB
TypeScript
1994 lines
83 KiB
TypeScript
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { useNavigation } from '@/contexts/NavigationContext';
|
|
import Navigation from '@/components/Navigation';
|
|
import AIRecruiter from '@/components/AIRecruiter';
|
|
import UserCreator from '@/components/UserCreator';
|
|
import UserCard from '@/components/UserCard';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download, MessageSquare } from 'lucide-react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Persona } from '@/types/persona';
|
|
import { usePersonaStorage } from '@/hooks/usePersonaStorage';
|
|
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
|
|
import { getSocket } from '@/services/websocketServiceNew';
|
|
import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api';
|
|
import { toastService } from '@/lib/toast';
|
|
import ProgressModal from '@/components/ui/ProgressModal';
|
|
import FolderTree from '@/components/FolderTree';
|
|
|
|
|
|
interface Folder {
|
|
_id: string;
|
|
id?: string; // Legacy field for compatibility
|
|
name: string;
|
|
parent_folder_id?: string | null;
|
|
level: number;
|
|
created_by?: string;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
// Note: No longer storing persona_ids - using persona-centric storage
|
|
}
|
|
|
|
const DEFAULT_FOLDER_ID = 'all';
|
|
|
|
// Define filter state interface
|
|
interface FilterState {
|
|
age: string[];
|
|
gender: string[];
|
|
occupation: string[];
|
|
location: string[];
|
|
techSavviness: string[];
|
|
ethnicity: string[];
|
|
folderStatus: string[];
|
|
}
|
|
|
|
const SyntheticUsers = () => {
|
|
// Helper function ONLY to ensure the body is interactive - memoized with useCallback
|
|
const ensureBodyInteractive = useCallback(() => {
|
|
if (document.body.style.pointerEvents === 'none') {
|
|
console.log('ensureBodyInteractive: Fixing body pointer-events...'); // Optional log
|
|
document.body.style.pointerEvents = 'auto';
|
|
}
|
|
}, []); // Empty dependency array because it has no external dependencies
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const { loadPersonas } = usePersonaStorage();
|
|
const { clearNavigationState, setPreviousRoute } = useNavigation();
|
|
|
|
const [mode, setMode] = useState<'view' | 'create'>('view');
|
|
const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai');
|
|
|
|
// WebSocket and cancellable generation for summary generation
|
|
const socket = getSocket();
|
|
const [summaryGenerationState, summaryGenerationControls] = useCancellableGeneration('persona summary generation', socket);
|
|
const [isSummaryProgressModalOpen, setIsSummaryProgressModalOpen] = useState(false);
|
|
const [summaryProgressDescription, setSummaryProgressDescription] = useState('');
|
|
// Bulk export no longer needs cancellable generation - it's instant
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedUser, setSelectedUser] = useState<Persona | null>(null);
|
|
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
|
|
|
|
// Handle URL parameters to set mode and folder
|
|
useEffect(() => {
|
|
const modeParam = searchParams.get('mode');
|
|
if (modeParam === 'view' || modeParam === 'create') {
|
|
setMode(modeParam);
|
|
}
|
|
|
|
// Handle folder parameter to restore folder selection
|
|
const folderParam = searchParams.get('folder');
|
|
if (folderParam) {
|
|
setSelectedFolder(folderParam);
|
|
}
|
|
}, [searchParams]);
|
|
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
|
|
const [folders, setFolders] = useState<Folder[]>([]);
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedPersonas, setSelectedPersonas] = useState<Set<string>>(new Set());
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false);
|
|
const [folderToDelete, setFolderToDelete] = useState<Folder | null>(null);
|
|
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
|
|
const [targetFolders, setTargetFolders] = useState<Set<string>>(new Set());
|
|
// Filter state
|
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
const [activeFilters, setActiveFilters] = useState<FilterState>({
|
|
age: [],
|
|
gender: [],
|
|
occupation: [],
|
|
location: [],
|
|
techSavviness: [],
|
|
ethnicity: [],
|
|
folderStatus: [],
|
|
});
|
|
// Working copy of filter state for the dialog
|
|
const [workingFilters, setWorkingFilters] = useState<FilterState>({
|
|
age: [],
|
|
gender: [],
|
|
occupation: [],
|
|
location: [],
|
|
techSavviness: [],
|
|
ethnicity: [],
|
|
folderStatus: [],
|
|
});
|
|
// LLM selection for download
|
|
const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false);
|
|
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gemini-3-pro-preview');
|
|
|
|
// Bulk export no longer needs state - direct download
|
|
|
|
// Handle summary generation progress completion
|
|
const handleSummaryProgressComplete = () => {
|
|
setIsSummaryProgressModalOpen(false);
|
|
summaryGenerationControls.resetGeneration();
|
|
};
|
|
|
|
// Handle navigation to persona details from synthetic users list
|
|
const handlePersonaClick = (persona: Persona) => {
|
|
// Clear any existing navigation state since we're coming from synthetic users
|
|
clearNavigationState();
|
|
// Navigate to persona details
|
|
navigate(`/synthetic-users/${persona._id || persona.id}`);
|
|
};
|
|
|
|
// Handle navigation to persona details with folder context
|
|
const handlePersonaViewDetails = (persona: Persona) => {
|
|
// Set navigation context to remember which folder we came from
|
|
setPreviousRoute('/synthetic-users', {
|
|
folderId: selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : undefined
|
|
});
|
|
// Navigate to persona details
|
|
navigate(`/synthetic-users/${persona._id || persona.id}`);
|
|
};
|
|
|
|
// Function to collect unique filter options from personas
|
|
const getFilterOptions = (personas: Persona[]) => {
|
|
const options = {
|
|
age: new Set<string>(),
|
|
gender: new Set<string>(),
|
|
occupation: new Set<string>(),
|
|
location: new Set<string>(),
|
|
techSavviness: new Set<string>(),
|
|
ethnicity: new Set<string>(),
|
|
};
|
|
|
|
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);
|
|
});
|
|
|
|
// 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(),
|
|
};
|
|
};
|
|
|
|
// Reset filters function
|
|
const resetFilters = () => {
|
|
setActiveFilters({
|
|
age: [],
|
|
gender: [],
|
|
occupation: [],
|
|
location: [],
|
|
techSavviness: [],
|
|
ethnicity: [],
|
|
folderStatus: [],
|
|
});
|
|
};
|
|
|
|
// Apply filters function
|
|
const applyFilters = () => {
|
|
// First close the dialog
|
|
setIsFilterOpen(false);
|
|
|
|
// Then apply the filters after stack clear
|
|
setTimeout(() => {
|
|
setActiveFilters({...workingFilters});
|
|
}, 0);
|
|
};
|
|
|
|
// Reset filters
|
|
const handleResetFilters = () => {
|
|
setWorkingFilters({
|
|
age: [],
|
|
gender: [],
|
|
occupation: [],
|
|
location: [],
|
|
techSavviness: [],
|
|
ethnicity: [],
|
|
folderStatus: [],
|
|
});
|
|
};
|
|
|
|
// 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 API
|
|
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
|
|
// Note: No longer processing persona_ids - using persona-centric storage
|
|
}));
|
|
|
|
setFolders(processedFolders);
|
|
return processedFolders;
|
|
} catch (error) {
|
|
console.error("Error fetching folders:", error);
|
|
toastService.error("Failed to load folders");
|
|
setFolders([]);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Extract fetchPersonas into a separate function to be used in multiple places
|
|
const fetchPersonas = async () => {
|
|
const isMounted = true;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
// Fetch personas from API using the proper API client
|
|
const response = await personasApi.getAll();
|
|
|
|
// API client already handles response parsing
|
|
const data = response.data;
|
|
if (isMounted) {
|
|
// Process API data to ensure consistent format
|
|
const processedData = data.map((p: Persona) => ({
|
|
...p,
|
|
// Ensure id is set for compatibility
|
|
id: p.id || p._id,
|
|
}));
|
|
|
|
// We're now only showing personas from the database, no local storage
|
|
const combinedPersonas = [...processedData];
|
|
|
|
// Load stored personas asynchronously only for data migration purposes
|
|
// but we won't show them to the user anymore
|
|
try {
|
|
const loadStoredPersonas = async () => {
|
|
const storedPersonas = await loadPersonas();
|
|
console.log('Loaded stored personas (for debugging only):', storedPersonas ? storedPersonas.length : 0);
|
|
};
|
|
loadStoredPersonas();
|
|
} catch (e) {
|
|
console.warn('Error loading stored personas:', e);
|
|
}
|
|
|
|
// Not loading local personas anymore
|
|
|
|
setAllPersonas(combinedPersonas);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching personas:", error);
|
|
if (isMounted) {
|
|
toastService.error("Failed to load personas");
|
|
setAllPersonas([]);
|
|
}
|
|
} finally {
|
|
if (isMounted) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Note: Folder synchronization is now handled server-side
|
|
|
|
// Initial load effect
|
|
useEffect(() => {
|
|
// Use a flag to track if component is still mounted
|
|
let isMounted = true;
|
|
|
|
// Fetch both folders and personas from API
|
|
const fetchData = async () => {
|
|
try {
|
|
// Fetch folders and personas in parallel
|
|
const [, ] = await Promise.all([
|
|
fetchFolders(),
|
|
fetchPersonas()
|
|
]);
|
|
} catch (error) {
|
|
console.error("Error loading data:", error);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
|
|
// Cleanup function to prevent memory leaks
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [ensureBodyInteractive]); // Add ensureBodyInteractive dependency
|
|
|
|
// Add an effect to refresh data when 'view' mode is activated
|
|
// This ensures personas are refreshed when navigating back from creation
|
|
useEffect(() => {
|
|
if (mode === 'view') {
|
|
fetchPersonas();
|
|
} else if (mode === 'create') {
|
|
// Log selected folder when switching to create mode
|
|
console.log(`Switching to create mode with folder: ${selectedFolder}, ${selectedFolder !== DEFAULT_FOLDER_ID ? 'NOT default' : 'IS default'}`);
|
|
if (selectedFolder !== DEFAULT_FOLDER_ID) {
|
|
const folderName = folders.find(f => f.id === selectedFolder)?.name;
|
|
console.log(`Selected folder for creation: ${selectedFolder} (${folderName})`);
|
|
}
|
|
}
|
|
}, [mode]);
|
|
|
|
// Note: Folder-persona relationships are now managed server-side
|
|
|
|
// Listen for navigation events to ensure data is refreshed when navigating to this page
|
|
useEffect(() => {
|
|
// This will run when the component mounts (user navigates to the page)
|
|
fetchPersonas();
|
|
|
|
// Create an event listener for route changes within the app
|
|
const handleRouteChange = () => {
|
|
// Check if we're on the synthetic users page
|
|
if (window.location.pathname.includes('/synthetic-users') &&
|
|
!window.location.pathname.includes('/synthetic-users/')) {
|
|
console.log('Navigation to synthetic users page detected, refreshing data');
|
|
fetchPersonas();
|
|
}
|
|
};
|
|
|
|
// Listen for the custom event from the Navigation component
|
|
const handleSyntheticUsersNavigation = () => {
|
|
console.log('Synthetic users navigation event detected, refreshing data');
|
|
fetchPersonas();
|
|
};
|
|
|
|
// --- ADD MutationObserver LOGIC ---
|
|
console.log('Setting up MutationObserver for body style');
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (
|
|
mutation.type === 'attributes' &&
|
|
mutation.attributeName === 'style' &&
|
|
document.body.style.pointerEvents === 'none'
|
|
) {
|
|
// Use the memoized function ensureBodyInteractive
|
|
console.log('MutationObserver detected pointer-events: none, fixing...');
|
|
ensureBodyInteractive();
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
attributes: true,
|
|
attributeFilter: ['style'],
|
|
});
|
|
// --- END ADD MutationObserver LOGIC ---
|
|
|
|
// Ensure body is interactive when component mounts initially
|
|
ensureBodyInteractive();
|
|
|
|
// Add event listeners
|
|
window.addEventListener('popstate', handleRouteChange);
|
|
window.addEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation);
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
window.removeEventListener('popstate', handleRouteChange);
|
|
window.removeEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation);
|
|
|
|
// --- ADD Observer Cleanup ---
|
|
console.log('Disconnecting MutationObserver');
|
|
observer.disconnect();
|
|
// --- END ADD Observer Cleanup ---
|
|
};
|
|
}, []);
|
|
|
|
// Note: Folders are now stored server-side, no localStorage needed
|
|
|
|
// Note: Server-side folder management eliminates need for manual synchronization
|
|
|
|
const createNewFolder = async (name: string, parentId?: string) => {
|
|
if (!name.trim()) {
|
|
toastService.error("Please enter a folder name");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const folderData: any = {
|
|
name: name.trim(),
|
|
persona_ids: []
|
|
};
|
|
|
|
if (parentId) {
|
|
folderData.parent_folder_id = parentId;
|
|
}
|
|
|
|
const response = await foldersApi.create(folderData);
|
|
|
|
// Refresh folders from server
|
|
await fetchFolders();
|
|
|
|
const folderType = parentId ? 'Sub-folder' : 'Folder';
|
|
toastService.success(`${folderType} "${name}" created`);
|
|
} catch (error) {
|
|
console.error("Error creating folder:", error);
|
|
const errorMessage = error.response?.data?.message || "Failed to create folder";
|
|
toastService.error(errorMessage);
|
|
}
|
|
};
|
|
|
|
const renameFolder = async (folderId: string, newName: string) => {
|
|
try {
|
|
await foldersApi.update(folderId, {
|
|
name: newName
|
|
});
|
|
|
|
// Refresh folders from server
|
|
await fetchFolders();
|
|
|
|
toastService.success(`Folder renamed to "${newName}"`);
|
|
} catch (error) {
|
|
console.error("Error renaming folder:", error);
|
|
const errorMessage = error.response?.data?.message || "Failed to rename folder";
|
|
toastService.error(errorMessage);
|
|
}
|
|
};
|
|
|
|
const moveFolder = async (folderId: string, newParentId: string | null) => {
|
|
try {
|
|
await foldersApi.moveFolder(folderId, newParentId);
|
|
|
|
// Refresh folders from server
|
|
await fetchFolders();
|
|
|
|
const targetName = newParentId
|
|
? folders.find(f => f._id === newParentId)?.name || 'folder'
|
|
: 'root level';
|
|
|
|
toastService.success(`Folder moved to ${targetName}`);
|
|
} catch (error) {
|
|
console.error("Error moving folder:", error);
|
|
const errorMessage = error.response?.data?.message || "Failed to move folder";
|
|
toastService.error(errorMessage);
|
|
}
|
|
};
|
|
|
|
const startDeleteFolder = (folder: Folder) => {
|
|
setFolderToDelete(folder);
|
|
setDeleteFolderConfirmOpen(true);
|
|
};
|
|
|
|
const completeDeleteFolder = async () => {
|
|
if (!folderToDelete) return;
|
|
|
|
try {
|
|
await foldersApi.delete(folderToDelete._id);
|
|
|
|
// Refresh folders from server
|
|
await fetchFolders();
|
|
|
|
// Update selected folder if the deleted folder was selected
|
|
if (selectedFolder === folderToDelete._id || selectedFolder === folderToDelete.id) {
|
|
setSelectedFolder(DEFAULT_FOLDER_ID);
|
|
}
|
|
|
|
setDeleteFolderConfirmOpen(false);
|
|
setFolderToDelete(null);
|
|
toastService.success(`Folder "${folderToDelete.name}" deleted`);
|
|
} catch (error) {
|
|
console.error("Error deleting folder:", error);
|
|
toastService.error("Failed to delete folder");
|
|
}
|
|
};
|
|
|
|
const movePersonasToFolder = async (personasToMove?: Set<string>, targetFolderIds?: Set<string>) => {
|
|
// Support both direct calls and calls from the dialog button
|
|
const personas = personasToMove || selectedPersonas;
|
|
const folderIds = targetFolderIds || targetFolders;
|
|
|
|
if (folderIds.size === 0 || personas.size === 0) return;
|
|
|
|
const personaIds = Array.from(personas);
|
|
|
|
// Convert persona.id to persona._id (MongoDB IDs) for backend operations
|
|
const mongoIds = personaIds.map(personaId => {
|
|
const persona = allPersonas.find(p => p.id === personaId);
|
|
return persona?._id || persona?.id || personaId;
|
|
}).filter(Boolean);
|
|
|
|
try {
|
|
const successfulUpdates: string[] = [];
|
|
const failedUpdates: string[] = [];
|
|
const folderNames: string[] = [];
|
|
|
|
// Handle "All Personas" selection (remove from all folders)
|
|
if (folderIds.has(DEFAULT_FOLDER_ID)) {
|
|
// Remove personas from all current folders
|
|
const currentFolderIds = new Set<string>();
|
|
personaIds.forEach(personaId => {
|
|
const persona = allPersonas.find(p => p.id === personaId);
|
|
if (persona?.folder_ids) {
|
|
persona.folder_ids.forEach(fid => currentFolderIds.add(fid));
|
|
}
|
|
});
|
|
|
|
for (const folderId of currentFolderIds) {
|
|
try {
|
|
await foldersApi.removePersonasBatch(folderId, mongoIds);
|
|
} catch (error) {
|
|
console.error("Error removing personas from folder:", error);
|
|
}
|
|
}
|
|
successfulUpdates.push(...personaIds);
|
|
folderNames.push("All Personas (removed from folders)");
|
|
} else {
|
|
// Add personas to multiple selected folders
|
|
for (const folderId of folderIds) {
|
|
try {
|
|
await foldersApi.addPersonasBatch(folderId, mongoIds);
|
|
successfulUpdates.push(...personaIds);
|
|
const folderName = folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder";
|
|
folderNames.push(folderName);
|
|
} catch (error) {
|
|
console.error(`Error adding personas to folder ${folderId}:`, error);
|
|
failedUpdates.push(...personaIds);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh data from server
|
|
await Promise.all([fetchFolders(), fetchPersonas()]);
|
|
|
|
// Show toast messages
|
|
if (successfulUpdates.length > 0) {
|
|
const folderList = folderNames.length > 1
|
|
? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1)
|
|
: folderNames[0];
|
|
toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`);
|
|
}
|
|
|
|
if (failedUpdates.length > 0) {
|
|
toastService.error(`Failed to add some personas to selected folders.`);
|
|
}
|
|
|
|
// Clear selection - caller can also handle this if needed
|
|
if (!personasToMove) {
|
|
setSelectedPersonas(new Set());
|
|
}
|
|
|
|
return {
|
|
success: successfulUpdates.length > 0,
|
|
successCount: successfulUpdates.length,
|
|
failureCount: failedUpdates.length
|
|
};
|
|
} catch (error) {
|
|
console.error("Error moving personas to folder:", error);
|
|
toastService.error("An unexpected error occurred while adding personas to folder.");
|
|
return { success: false, error };
|
|
}
|
|
};
|
|
|
|
const addPersonaToFolder = async (personaId: string, folderId: string) => {
|
|
// First, update local folder state
|
|
const updatedFolders = folders.map(folder => {
|
|
// Add to target folder if not already included
|
|
if (folder.id === folderId) {
|
|
if (!folder.personaIds.includes(personaId)) {
|
|
return {
|
|
...folder,
|
|
personaIds: [...folder.personaIds, personaId]
|
|
};
|
|
}
|
|
}
|
|
// Remove from other folders
|
|
else {
|
|
return {
|
|
...folder,
|
|
personaIds: folder.personaIds.filter(id => id !== personaId)
|
|
};
|
|
}
|
|
return folder;
|
|
});
|
|
|
|
setFolders(updatedFolders);
|
|
|
|
// Then, update the persona in the database with the new folder
|
|
try {
|
|
const persona = allPersonas.find(p => p.id === personaId);
|
|
if (persona) {
|
|
// Prepare updated persona with folder information
|
|
const updatedPersona = {
|
|
...persona,
|
|
folderId: folderId
|
|
};
|
|
|
|
// Find the right ID to use
|
|
const idToUse = persona._id || persona.id;
|
|
|
|
// Update locally first for immediate feedback
|
|
setAllPersonas(prev => prev.map(p =>
|
|
p.id === personaId ? { ...p, folderId: folderId } : p
|
|
));
|
|
|
|
// Call API to update the persona
|
|
await personasApi.update(idToUse, updatedPersona);
|
|
|
|
// Add small delay to prevent UI interaction issues
|
|
setTimeout(() => {
|
|
toastService.success("Persona added to folder");
|
|
}, 100);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to update persona folder:", error);
|
|
setTimeout(() => {
|
|
toastService.error("Failed to update persona folder");
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
const removeSelectedPersonasFromCurrentFolder = async () => {
|
|
if (selectedPersonas.size === 0 || selectedFolder === DEFAULT_FOLDER_ID) return;
|
|
|
|
const selectedIds = Array.from(selectedPersonas);
|
|
|
|
// Convert persona.id to persona._id (MongoDB IDs) for backend operations
|
|
const mongoIds = selectedIds.map(personaId => {
|
|
const persona = allPersonas.find(p => p.id === personaId);
|
|
return persona?._id || persona?.id || personaId;
|
|
}).filter(Boolean);
|
|
|
|
console.log('Removing personas from folder:', {
|
|
selectedFolder,
|
|
selectedIds,
|
|
mongoIds,
|
|
folderName: folders.find(f => f._id === selectedFolder)?.name
|
|
});
|
|
|
|
try {
|
|
// Remove personas from the current folder using the batch API
|
|
await foldersApi.removePersonasBatch(selectedFolder, mongoIds);
|
|
|
|
// Refresh data from server
|
|
await Promise.all([fetchFolders(), fetchPersonas()]);
|
|
|
|
const folderName = folders.find(f => f._id === selectedFolder)?.name || 'folder';
|
|
toastService.success(`Removed ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} from ${folderName}`);
|
|
|
|
// Clear selection
|
|
setSelectedPersonas(new Set());
|
|
} catch (error) {
|
|
console.error("Error removing personas from folder:", error);
|
|
console.error("Error details:", error.response?.data || error.message);
|
|
toastService.error("Failed to remove personas from folder");
|
|
}
|
|
};
|
|
|
|
const togglePersonaSelection = (personaId: string) => {
|
|
setSelectedPersonas(prevSelected => {
|
|
const newSelected = new Set(prevSelected);
|
|
if (newSelected.has(personaId)) {
|
|
newSelected.delete(personaId);
|
|
} else {
|
|
newSelected.add(personaId);
|
|
}
|
|
return newSelected;
|
|
});
|
|
};
|
|
|
|
const handleSelectAllPersonas = () => {
|
|
if (selectedPersonas.size === filteredPersonas.length) {
|
|
// Deselect all if all are currently selected
|
|
setSelectedPersonas(new Set());
|
|
} else {
|
|
// Select all filtered personas
|
|
setSelectedPersonas(new Set(filteredPersonas.map(p => p.id)));
|
|
}
|
|
};
|
|
|
|
const deleteSelectedPersonas = async () => {
|
|
if (selectedPersonas.size === 0) return;
|
|
|
|
// Capture selected persona IDs before any state changes
|
|
const selectedIds = Array.from(selectedPersonas);
|
|
|
|
// Clear the selection and close dialog immediately
|
|
setSelectedPersonas(new Set()); // Clear selection first
|
|
setDeleteConfirmOpen(false); // Close modal second
|
|
setIsLoading(true); // Show loading state last
|
|
|
|
const successfulDeletes: string[] = [];
|
|
const failedDeletes: string[] = [];
|
|
|
|
// Process personas one by one to better handle errors
|
|
for (const id of selectedIds) {
|
|
try {
|
|
// Find the complete persona object to get the MongoDB _id
|
|
const persona = allPersonas.find(p => p.id === id);
|
|
if (!persona) {
|
|
console.error(`Could not find persona with id: ${id}`);
|
|
failedDeletes.push(id);
|
|
continue;
|
|
}
|
|
|
|
// Use the appropriate ID for the API call
|
|
// The backend expects a MongoDB ObjectId string
|
|
let idToUse = id;
|
|
if (persona._id) {
|
|
idToUse = persona._id.toString();
|
|
}
|
|
|
|
console.log(`Attempting to delete persona: ${idToUse}`);
|
|
await personasApi.delete(idToUse);
|
|
successfulDeletes.push(id);
|
|
} catch (error) {
|
|
console.error(`Failed to delete persona ${id}:`, error);
|
|
failedDeletes.push(id);
|
|
// Don't show toast for each failure to avoid spamming the user
|
|
}
|
|
}
|
|
|
|
// Update the personas list to remove successfully deleted personas
|
|
setAllPersonas(prev => prev.filter(persona => !successfulDeletes.includes(persona.id)));
|
|
|
|
// Refresh folders from server to reflect persona deletions
|
|
await fetchFolders();
|
|
|
|
setIsLoading(false);
|
|
|
|
// Show success/failure messages with a small delay
|
|
setTimeout(() => {
|
|
if (successfulDeletes.length > 0) {
|
|
toastService.success(`Successfully deleted ${successfulDeletes.length} persona${successfulDeletes.length !== 1 ? 's' : ''}`);
|
|
}
|
|
|
|
if (failedDeletes.length > 0) {
|
|
toastService.error(`Failed to delete ${failedDeletes.length} persona${failedDeletes.length !== 1 ? 's' : ''}`);
|
|
}
|
|
|
|
// Refresh the personas list to ensure consistency
|
|
if (successfulDeletes.length > 0 || failedDeletes.length > 0) {
|
|
fetchPersonas();
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
const filteredPersonas = allPersonas.filter(persona => {
|
|
// Text search matching
|
|
const matchesSearch = (
|
|
persona.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
persona.occupation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
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)'
|
|
))) &&
|
|
// Match folder status filter
|
|
(activeFilters.folderStatus.length === 0 ||
|
|
// If both options selected, show all personas (OR logic)
|
|
(activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) ||
|
|
// If only "hasFolder" selected
|
|
(activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') &&
|
|
((persona.folder_ids && persona.folder_ids.length > 0) ||
|
|
(persona.folder_id && persona.folder_id !== DEFAULT_FOLDER_ID) ||
|
|
(persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID))) ||
|
|
// If only "noFolder" selected
|
|
(activeFilters.folderStatus.includes('noFolder') && !activeFilters.folderStatus.includes('hasFolder') &&
|
|
((!persona.folder_ids || persona.folder_ids.length === 0) &&
|
|
(!persona.folder_id || persona.folder_id === DEFAULT_FOLDER_ID) &&
|
|
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID)))
|
|
);
|
|
|
|
// First check if the selected folder is "All Personas"
|
|
if (selectedFolder === DEFAULT_FOLDER_ID) {
|
|
return matchesSearch && matchesFilters;
|
|
}
|
|
|
|
// Check if the persona belongs to the selected folder (persona-centric storage)
|
|
// Check if the persona has this folder in its folder_ids array
|
|
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
|
|
const isInFolder = persona.folder_ids.includes(selectedFolder);
|
|
if (isInFolder) {
|
|
return matchesSearch && matchesFilters;
|
|
}
|
|
}
|
|
|
|
// Legacy support: Check using the single folder_id property
|
|
if (persona.folder_id === selectedFolder) {
|
|
return matchesSearch && matchesFilters;
|
|
}
|
|
|
|
// Also check legacy folderId for backwards compatibility
|
|
if (persona.folderId === selectedFolder) {
|
|
return matchesSearch && matchesFilters;
|
|
}
|
|
|
|
// No folder membership found
|
|
return false;
|
|
});
|
|
|
|
// Generate markdown content for persona summary export
|
|
const generatePersonaSummaryMarkdown = (personas: Persona[], folderName: string) => {
|
|
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
const personaCount = personas.length;
|
|
|
|
let markdown = `# Persona Summary Report\n\n`;
|
|
markdown += `**Folder:** ${folderName}\n`;
|
|
markdown += `**Date:** ${currentDate}\n`;
|
|
markdown += `**Total Personas:** ${personaCount}\n\n`;
|
|
|
|
if (personaCount === 0) {
|
|
markdown += `No personas found in this folder.\n`;
|
|
return markdown;
|
|
}
|
|
|
|
personas.forEach((persona, index) => {
|
|
// Persona header
|
|
markdown += `## ${persona.name}\n\n`;
|
|
|
|
// Demographics
|
|
markdown += `### Demographics\n`;
|
|
markdown += `- **Age:** ${persona.age}\n`;
|
|
markdown += `- **Gender:** ${persona.gender}\n`;
|
|
markdown += `- **Occupation:** ${persona.occupation}\n`;
|
|
markdown += `- **Location:** ${persona.location}\n\n`;
|
|
|
|
// AI-Synthesized Bio
|
|
if (persona.aiSynthesizedBio) {
|
|
markdown += `### AI-Synthesized Bio\n`;
|
|
markdown += `${persona.aiSynthesizedBio}\n\n`;
|
|
}
|
|
|
|
// Qualitative Attributes
|
|
if (persona.qualitativeAttributes && persona.qualitativeAttributes.length > 0) {
|
|
markdown += `### Key Attributes\n`;
|
|
persona.qualitativeAttributes.forEach(attribute => {
|
|
markdown += `- 🏷️ ${attribute}\n`;
|
|
});
|
|
markdown += `\n`;
|
|
}
|
|
|
|
// Top Personality Traits
|
|
if (persona.topPersonalityTraits && persona.topPersonalityTraits.length > 0) {
|
|
markdown += `### Top Personality Traits\n`;
|
|
persona.topPersonalityTraits.forEach(trait => {
|
|
markdown += `- 🧠 ${trait}\n`;
|
|
});
|
|
markdown += `\n`;
|
|
}
|
|
|
|
// Add horizontal rule between personas (except for the last one)
|
|
if (index < personas.length - 1) {
|
|
markdown += `---\n\n`;
|
|
}
|
|
});
|
|
|
|
return markdown;
|
|
};
|
|
|
|
// Download persona summary for current folder
|
|
const downloadPersonaSummary = async () => {
|
|
if (filteredPersonas.length === 0) {
|
|
toastService.error("No personas to download");
|
|
return;
|
|
}
|
|
|
|
// Show LLM selection modal immediately
|
|
setDownloadLlmModalOpen(true);
|
|
};
|
|
|
|
// Handle the actual download with selected model
|
|
const handleDownloadWithModel = async () => {
|
|
const folderName = selectedFolder === DEFAULT_FOLDER_ID
|
|
? 'All Personas'
|
|
: folders.find(f => f.id === selectedFolder)?.name || 'Unknown Folder';
|
|
|
|
// Extract persona IDs, using _id for database personas or id as fallback
|
|
const personaIds = filteredPersonas.map(persona => persona._id || persona.id);
|
|
|
|
// Log user's model selection
|
|
console.log(`🤖 Frontend: User selected ${selectedDownloadLlmModel} for persona summary download`);
|
|
|
|
// Close modal
|
|
setDownloadLlmModalOpen(false);
|
|
|
|
// Start cancellable generation and open progress modal
|
|
summaryGenerationControls.startGeneration();
|
|
setSummaryProgressDescription(`Generating AI-powered summaries for ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''}. This may take a few minutes.`);
|
|
setIsSummaryProgressModalOpen(true);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
// Show initial toast with progress
|
|
toastService.info("Generating persona summaries...", {
|
|
description: `Processing ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''} with AI`
|
|
});
|
|
|
|
// Call the new API endpoint for batch summary generation
|
|
const response = await aiPersonasApi.batchGenerateSummaries(personaIds, 0.7, selectedDownloadLlmModel);
|
|
const { summaries, summary_stats, errors, task_id } = response.data;
|
|
|
|
// Set task ID for cancellation
|
|
if (task_id) {
|
|
summaryGenerationControls.setTaskId(task_id);
|
|
}
|
|
|
|
// Generate markdown content from LLM-processed summaries
|
|
const currentDate = new Date().toISOString().split('T')[0];
|
|
const fileName = `persona-summary-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`;
|
|
|
|
let markdownContent = `# Persona Summary Report\n\n`;
|
|
markdownContent += `**Folder:** ${folderName}\n`;
|
|
markdownContent += `**Date:** ${currentDate}\n`;
|
|
markdownContent += `**Total Personas:** ${summary_stats.total_requested}\n`;
|
|
markdownContent += `**Successfully Processed:** ${summary_stats.total_successful}\n`;
|
|
|
|
if (summary_stats.total_failed > 0) {
|
|
markdownContent += `**Failed to Process:** ${summary_stats.total_failed}\n`;
|
|
}
|
|
|
|
markdownContent += `\n---\n\n`;
|
|
|
|
if (summaries.length === 0) {
|
|
markdownContent += `No persona summaries could be generated.\n`;
|
|
} else {
|
|
// Add each persona summary
|
|
summaries.forEach((summaryData, index) => {
|
|
markdownContent += `# ${summaryData.persona_name}\n\n`;
|
|
markdownContent += `${summaryData.summary}\n\n`;
|
|
|
|
// Add separator between personas (except for the last one)
|
|
if (index < summaries.length - 1) {
|
|
markdownContent += `---\n\n`;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add error section if there were failures
|
|
if (errors && (errors.failed_summaries?.length > 0 || errors.missing_personas?.length > 0)) {
|
|
markdownContent += `\n---\n\n## Processing Errors\n\n`;
|
|
|
|
if (errors.failed_summaries?.length > 0) {
|
|
markdownContent += `### Failed to Generate Summaries\n`;
|
|
errors.failed_summaries.forEach(failure => {
|
|
markdownContent += `- **${failure.persona_name}** (ID: ${failure.persona_id}): ${failure.error}\n`;
|
|
});
|
|
markdownContent += `\n`;
|
|
}
|
|
|
|
if (errors.missing_personas?.length > 0) {
|
|
markdownContent += `### Missing Personas\n`;
|
|
errors.missing_personas.forEach(id => {
|
|
markdownContent += `- ID: ${id}\n`;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create and download the file
|
|
const element = document.createElement('a');
|
|
const file = new Blob([markdownContent], { type: 'text/markdown' });
|
|
element.href = URL.createObjectURL(file);
|
|
element.download = fileName;
|
|
document.body.appendChild(element);
|
|
element.click();
|
|
document.body.removeChild(element);
|
|
|
|
// Mark generation as complete
|
|
summaryGenerationControls.completeGeneration();
|
|
|
|
// Show success toast with details including model information
|
|
const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 3 Pro';
|
|
if (summary_stats.total_successful === summary_stats.total_requested) {
|
|
toastService.success("Persona summary downloaded", {
|
|
description: `Successfully processed all ${summary_stats.total_successful} persona${summary_stats.total_successful !== 1 ? 's' : ''} from "${folderName}" using ${modelDisplayName}`
|
|
});
|
|
} else {
|
|
toastService.success("Persona summary downloaded with warnings", {
|
|
description: `Processed ${summary_stats.total_successful} of ${summary_stats.total_requested} personas from "${folderName}" using ${modelDisplayName}`
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
// Check if this was a cancellation (expected behavior)
|
|
if (error.response?.status === 499) {
|
|
return;
|
|
}
|
|
|
|
console.error("Error generating persona summaries:", error);
|
|
|
|
// Mark generation as failed
|
|
summaryGenerationControls.failGeneration(error.message || 'Failed to generate summaries');
|
|
|
|
// Fall back to basic summary if API fails
|
|
toastService.error("AI summary generation failed, creating basic summary", {
|
|
description: "Using simplified format due to processing error"
|
|
});
|
|
|
|
try {
|
|
const currentDate = new Date().toISOString().split('T')[0];
|
|
const fileName = `persona-summary-basic-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`;
|
|
const basicContent = generatePersonaSummaryMarkdown(filteredPersonas, folderName);
|
|
|
|
const element = document.createElement('a');
|
|
const file = new Blob([basicContent], { type: 'text/markdown' });
|
|
element.href = URL.createObjectURL(file);
|
|
element.download = fileName;
|
|
document.body.appendChild(element);
|
|
element.click();
|
|
document.body.removeChild(element);
|
|
|
|
} catch (fallbackError) {
|
|
toastService.error("Failed to create persona summary", {
|
|
description: "Unable to generate summary in any format"
|
|
});
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Bulk export functions
|
|
const handleBulkExport = async (format: 'markdown' | 'json' | 'csv') => {
|
|
const selectedIds = Array.from(selectedPersonas);
|
|
if (selectedIds.length === 0) {
|
|
toastService.error("Please select personas to export");
|
|
return;
|
|
}
|
|
|
|
// Show loading toast
|
|
toastService.info(`Exporting ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} to ${format.toUpperCase()}...`);
|
|
|
|
try {
|
|
// Get JWT token for the request
|
|
const token = localStorage.getItem('auth_token');
|
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api';
|
|
|
|
// Make direct fetch request since response will be a file
|
|
const response = await fetch(`${API_BASE_URL}/personas/bulk-export`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
persona_ids: selectedIds,
|
|
export_format: format
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Export failed');
|
|
}
|
|
|
|
// The response should be the file blob directly
|
|
const blob = await response.blob();
|
|
|
|
// Create download link
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `persona_profiles_${format}_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.zip`;
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Clean up
|
|
URL.revokeObjectURL(url);
|
|
|
|
toastService.success(`${format.toUpperCase()} export completed!`, {
|
|
description: `Successfully exported ${selectedIds.length} persona profile${selectedIds.length !== 1 ? 's' : ''}`
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error("Error during bulk export:", error);
|
|
|
|
toastService.error("Export failed", {
|
|
description: error.message || 'Unknown error occurred'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Removed separate download function - now using direct file response
|
|
|
|
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">Synthetic Personas</h1>
|
|
<p className="text-slate-600 mt-1">Create and manage AI-generated research participants</p>
|
|
</div>
|
|
|
|
<div className="mt-4 sm:mt-0 flex flex-col items-end gap-3">
|
|
<div className="flex items-center gap-3">
|
|
{mode === 'view' && filteredPersonas.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={downloadPersonaSummary}
|
|
disabled={summaryGenerationState.isGenerating}
|
|
className="flex items-center gap-2 hover-transition"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
{summaryGenerationState.isGenerating ? 'Generating Summary...' : 'Download Persona Summary'}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={() => setMode(mode === 'view' ? 'create' : 'view')}
|
|
className="hover-transition"
|
|
>
|
|
{mode === 'view' ? 'Create New Personas' : 'View All Personas'}
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Modal for Persona Summary Generation */}
|
|
<ProgressModal
|
|
isOpen={isSummaryProgressModalOpen}
|
|
onClose={() => setIsSummaryProgressModalOpen(false)}
|
|
isActive={summaryGenerationState.isGenerating}
|
|
isComplete={summaryGenerationState.isComplete}
|
|
hasError={summaryGenerationState.hasError}
|
|
isCancelling={summaryGenerationState.isCancelling}
|
|
taskId={summaryGenerationState.taskId}
|
|
title="Generating Persona Summaries"
|
|
description={summaryProgressDescription}
|
|
onCancel={summaryGenerationControls.cancelGeneration}
|
|
onComplete={handleSummaryProgressComplete}
|
|
/>
|
|
|
|
{mode === 'view' ? (
|
|
<>
|
|
<div className="flex flex-col md:flex-row gap-6 mb-6">
|
|
<FolderTree
|
|
folders={folders}
|
|
allPersonas={allPersonas}
|
|
selectedFolder={selectedFolder}
|
|
onFolderSelect={setSelectedFolder}
|
|
onCreateFolder={createNewFolder}
|
|
onRenameFolder={renameFolder}
|
|
onDeleteFolder={startDeleteFolder}
|
|
onMoveFolder={moveFolder}
|
|
defaultFolderId={DEFAULT_FOLDER_ID}
|
|
/>
|
|
|
|
<div className="flex-1">
|
|
<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 personas by name, occupation, or location..."
|
|
className="pl-10 bg-white"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{selectedPersonas.size > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => {
|
|
// Prevent any click events from propagating
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<span>Actions ({selectedPersonas.size})</span>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
onCloseAutoFocus={(e) => {
|
|
// Prevent focusing after close which can cause issues
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const selectedIds = Array.from(selectedPersonas);
|
|
navigate('/focus-groups', {
|
|
state: {
|
|
mode: 'create',
|
|
preSelectedParticipants: selectedIds
|
|
}
|
|
});
|
|
}}
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
Create Focus Group with selected Personas
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDeleteConfirmOpen(true);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setMoveToFolderOpen(true);
|
|
}}
|
|
>
|
|
<Folder className="h-4 w-4" />
|
|
Move to folder
|
|
</DropdownMenuItem>
|
|
{selectedFolder !== DEFAULT_FOLDER_ID && (
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
removeSelectedPersonasFromCurrentFolder();
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Remove from {folders.find(f => f._id === selectedFolder)?.name || 'folder'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleBulkExport('markdown');
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download Full Persona Profiles (Markdown)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleBulkExport('json');
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download Full Persona Profiles (JSON)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleBulkExport('csv');
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download Full Persona Profiles (CSV)
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
className="flex items-center gap-2"
|
|
onClick={() => setIsFilterOpen(true)}
|
|
>
|
|
<Filter className="h-4 w-4" />
|
|
<span>Filter{Object.values(activeFilters).some(arr => arr.length > 0) ? ` (${Object.values(activeFilters).reduce((count, arr) => count + arr.length, 0)})` : ''}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass-panel rounded-xl p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-5 w-5 text-primary" />
|
|
<h2 className="font-sf text-xl font-semibold">
|
|
{selectedFolder === DEFAULT_FOLDER_ID
|
|
? 'Your Synthetic Persona Library'
|
|
: folders.find(f => f._id === selectedFolder)?.name || 'Personas'}
|
|
</h2>
|
|
<span className="text-sm text-muted-foreground">
|
|
({filteredPersonas.length})
|
|
</span>
|
|
</div>
|
|
|
|
{filteredPersonas.length > 0 && (
|
|
<div className="flex items-center">
|
|
<Checkbox
|
|
id="select-all"
|
|
checked={filteredPersonas.length > 0 && selectedPersonas.size === filteredPersonas.length}
|
|
onCheckedChange={handleSelectAllPersonas}
|
|
className="mr-2"
|
|
/>
|
|
<label htmlFor="select-all" className="text-sm cursor-pointer">
|
|
Select All
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{filteredPersonas.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 gap-4">
|
|
{filteredPersonas.map((persona) => (
|
|
<div key={persona.id} className="relative group">
|
|
<UserCard
|
|
user={persona}
|
|
selected={selectedPersonas.has(persona.id)}
|
|
onClick={() => handlePersonaClick(persona)}
|
|
onSelectionToggle={(e) => {
|
|
e.stopPropagation();
|
|
togglePersonaSelection(persona.id);
|
|
}}
|
|
onViewDetails={handlePersonaViewDetails}
|
|
showAddToFolderButton={false}
|
|
folders={folders}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<p className="text-muted-foreground">No personas found matching your criteria.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<AlertDialog
|
|
open={deleteConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
// If dialog is closing without delete action
|
|
if (!open) {
|
|
setDeleteConfirmOpen(false);
|
|
} else {
|
|
setDeleteConfirmOpen(open);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent
|
|
onInteractOutside={(e) => {
|
|
// Prevent interaction with outside elements but do not block pointer events
|
|
// This prevents clicks from going through but won't disable the whole body
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Personas</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''}?
|
|
This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => {
|
|
// Clear selection after canceling
|
|
setTimeout(() => setSelectedPersonas(new Set()), 50);
|
|
}}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={deleteSelectedPersonas}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog
|
|
open={deleteFolderConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteFolderConfirmOpen(false);
|
|
} else {
|
|
setDeleteFolderConfirmOpen(open);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Folder</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete the folder "{folderToDelete?.name}"?
|
|
<br /><br />
|
|
<strong>Note:</strong> Any personas in this folder will not be deleted - they will still be available under 'All Personas' after folder deletion.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={completeDeleteFolder}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<Dialog
|
|
open={moveToFolderOpen}
|
|
onOpenChange={(open) => {
|
|
// Handle dialog open/close state
|
|
if (!open) {
|
|
// Close the dialog
|
|
setMoveToFolderOpen(false);
|
|
} else {
|
|
setMoveToFolderOpen(open);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="z-50" // Ensure highest z-index
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Move to Folder</DialogTitle>
|
|
<DialogDescription>
|
|
Choose one or more folders to add {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. Personas can belong to multiple folders.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="py-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="folder-all"
|
|
checked={targetFolders.has(DEFAULT_FOLDER_ID)}
|
|
onCheckedChange={(checked) => {
|
|
const newTargetFolders = new Set(targetFolders);
|
|
if (checked) {
|
|
// If "All Personas" is selected, clear all other selections
|
|
setTargetFolders(new Set([DEFAULT_FOLDER_ID]));
|
|
} else {
|
|
newTargetFolders.delete(DEFAULT_FOLDER_ID);
|
|
setTargetFolders(newTargetFolders);
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor="folder-all" className="flex items-center gap-2 cursor-pointer">
|
|
<Folder className="h-4 w-4" />
|
|
<span>All Personas (Remove from folders)</span>
|
|
</Label>
|
|
</div>
|
|
|
|
{(() => {
|
|
// Organize folders into hierarchy for the dialog
|
|
const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0);
|
|
const childMap: Record<string, Folder[]> = {};
|
|
|
|
folders.forEach(folder => {
|
|
if (folder.parent_folder_id && folder.level > 0) {
|
|
if (!childMap[folder.parent_folder_id]) {
|
|
childMap[folder.parent_folder_id] = [];
|
|
}
|
|
childMap[folder.parent_folder_id].push(folder);
|
|
}
|
|
});
|
|
|
|
const handleFolderToggle = (folderId: string, checked: boolean) => {
|
|
const newTargetFolders = new Set(targetFolders);
|
|
if (checked) {
|
|
// If selecting a regular folder, remove "All Personas" if it was selected
|
|
newTargetFolders.delete(DEFAULT_FOLDER_ID);
|
|
newTargetFolders.add(folderId);
|
|
} else {
|
|
newTargetFolders.delete(folderId);
|
|
}
|
|
setTargetFolders(newTargetFolders);
|
|
};
|
|
|
|
const renderFolderOption = (folder: Folder, isChild = false) => (
|
|
<div key={folder._id}>
|
|
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
|
|
<Checkbox
|
|
id={`folder-${folder._id}`}
|
|
checked={targetFolders.has(folder._id)}
|
|
onCheckedChange={(checked) => handleFolderToggle(folder._id, !!checked)}
|
|
/>
|
|
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
|
|
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
|
|
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
|
|
</Label>
|
|
</div>
|
|
{/* Render children if any */}
|
|
{childMap[folder._id] && childMap[folder._id].map(childFolder =>
|
|
renderFolderOption(childFolder, true)
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return rootFolders.map(folder => renderFolderOption(folder));
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
// Prevent event propagation
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Close dialog, don't touch selections
|
|
setMoveToFolderOpen(false);
|
|
setTargetFolders(new Set());
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={async (e) => {
|
|
// Prevent default behavior and stop propagation
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (targetFolders.size === 0) return; // Ensure target folders are selected
|
|
|
|
// Capture values before closing dialog or clearing state
|
|
const currentSelectedPersonas = new Set(selectedPersonas);
|
|
const currentTargetFolders = new Set(targetFolders);
|
|
|
|
// Close dialog immediately for better UX
|
|
setMoveToFolderOpen(false);
|
|
setTargetFolders(new Set()); // Reset target folders state
|
|
|
|
// Perform the move operation
|
|
if (currentTargetFolders.size > 0 && currentSelectedPersonas.size > 0) {
|
|
setIsLoading(true); // Show loading indicator
|
|
|
|
try {
|
|
// Call the move function with captured values
|
|
await movePersonasToFolder(currentSelectedPersonas, currentTargetFolders);
|
|
} finally {
|
|
setIsLoading(false); // Hide loading indicator first
|
|
|
|
// Clear selection after the operation is complete
|
|
setSelectedPersonas(new Set());
|
|
}
|
|
}
|
|
}}
|
|
disabled={targetFolders.size === 0}
|
|
>
|
|
Move
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Filter Dialog */}
|
|
<Dialog
|
|
open={isFilterOpen}
|
|
onOpenChange={(open) => {
|
|
// Important: Cancel any pending selections first
|
|
if (!open) {
|
|
if (selectedPersonas.size > 0) {
|
|
setSelectedPersonas(new Set());
|
|
}
|
|
|
|
// Then update filter state
|
|
setIsFilterOpen(false);
|
|
} else {
|
|
setIsFilterOpen(open);
|
|
// When opening, set working filters to active filters
|
|
setWorkingFilters({...activeFilters});
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="max-w-4xl max-h-[80vh] flex flex-col"
|
|
onInteractOutside={(e) => {
|
|
// Prevent interaction with outside elements but do not block pointer events
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
{/* Sticky Header */}
|
|
<div className="sticky top-0 bg-background border-b shadow-sm pb-4 z-10">
|
|
<DialogHeader>
|
|
<DialogTitle>Filter Personas</DialogTitle>
|
|
<DialogDescription>
|
|
Select attributes to filter personas by. Multiple selections within a category use OR logic,
|
|
different categories use AND logic. Filter options dynamically update to show only relevant values.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
</div>
|
|
|
|
{/* Scrollable Content Area */}
|
|
<div className="flex-1 overflow-y-auto px-1 py-4 space-y-6">
|
|
{/* Display number of active filters */}
|
|
{Object.values(workingFilters).some(arr => arr.length > 0) && (
|
|
<div className="bg-muted/30 p-3 rounded-md">
|
|
<p className="text-sm text-muted-foreground">
|
|
{Object.values(workingFilters).reduce((count, arr) => count + arr.length, 0)} active filters
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Basic Demographics Filters */}
|
|
<div className="space-y-4">
|
|
{(() => {
|
|
// Get filter options from filtered personas based on current selections
|
|
// We need to find which personas match the current filters (excluding the category being viewed)
|
|
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 = allPersonas.filter(persona => {
|
|
// 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);
|
|
};
|
|
|
|
// If no filters are active, use all personas
|
|
const noActiveFilters = Object.values(workingFilters).every(values => values.length === 0);
|
|
const filterOptions = noActiveFilters
|
|
? getFilterOptions(allPersonas)
|
|
: getFilterOptions(allPersonas); // Default, will be overridden for each category
|
|
|
|
// Render filter section
|
|
const renderFilterSection = (
|
|
title: string,
|
|
category: keyof FilterState,
|
|
options: string[],
|
|
columns: number = 1
|
|
) => {
|
|
// Include already selected options that might be filtered out
|
|
// This ensures that users can see and unselect options they've already selected
|
|
const selectedOptions = workingFilters[category];
|
|
|
|
// Combine filtered options with selected options
|
|
const combinedOptions = [...new Set([...options, ...selectedOptions])].sort();
|
|
|
|
if (combinedOptions.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-medium mb-3">{title}</h3>
|
|
<div className={`grid grid-cols-1 ${columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 md:grid-cols-3' : ''} gap-2`}>
|
|
{combinedOptions.map(option => {
|
|
const isSelected = workingFilters[category].includes(option);
|
|
const isAvailable = options.includes(option);
|
|
|
|
return (
|
|
<div
|
|
key={option}
|
|
className={`flex items-center space-x-2 ${!isAvailable && !isSelected ? 'opacity-50' : ''}`}
|
|
>
|
|
<Checkbox
|
|
id={`${category}-${option}`}
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleFilter(category, option)}
|
|
disabled={!isAvailable && !isSelected}
|
|
/>
|
|
<Label
|
|
htmlFor={`${category}-${option}`}
|
|
className="truncate overflow-hidden"
|
|
>
|
|
{option}
|
|
{isSelected && !isAvailable && (
|
|
<span className="ml-1 text-xs text-muted-foreground">(no matches)</span>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{renderFilterSection(
|
|
'Gender',
|
|
'gender',
|
|
noActiveFilters ? filterOptions.gender : getFilteredOptions('gender').gender,
|
|
3
|
|
)}
|
|
{renderFilterSection(
|
|
'Age',
|
|
'age',
|
|
noActiveFilters ? filterOptions.age : getFilteredOptions('age').age,
|
|
3
|
|
)}
|
|
{renderFilterSection(
|
|
'Ethnicity',
|
|
'ethnicity',
|
|
noActiveFilters ? filterOptions.ethnicity : getFilteredOptions('ethnicity').ethnicity,
|
|
2
|
|
)}
|
|
{renderFilterSection(
|
|
'Location',
|
|
'location',
|
|
noActiveFilters ? filterOptions.location : getFilteredOptions('location').location,
|
|
2
|
|
)}
|
|
{renderFilterSection(
|
|
'Occupation',
|
|
'occupation',
|
|
noActiveFilters ? filterOptions.occupation : getFilteredOptions('occupation').occupation,
|
|
2
|
|
)}
|
|
|
|
{/* Tech Savviness */}
|
|
{renderFilterSection(
|
|
'Tech Savviness',
|
|
'techSavviness',
|
|
noActiveFilters ? filterOptions.techSavviness : getFilteredOptions('techSavviness').techSavviness,
|
|
3
|
|
)}
|
|
|
|
{/* Folder Assignment */}
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-medium mb-3">Folder Assignment</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="folderStatus-hasFolder"
|
|
checked={workingFilters.folderStatus.includes('hasFolder')}
|
|
onCheckedChange={() => toggleFilter('folderStatus', 'hasFolder')}
|
|
/>
|
|
<Label
|
|
htmlFor="folderStatus-hasFolder"
|
|
className="truncate overflow-hidden"
|
|
>
|
|
Has folder assignment
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="folderStatus-noFolder"
|
|
checked={workingFilters.folderStatus.includes('noFolder')}
|
|
onCheckedChange={() => toggleFilter('folderStatus', 'noFolder')}
|
|
/>
|
|
<Label
|
|
htmlFor="folderStatus-noFolder"
|
|
className="truncate overflow-hidden"
|
|
>
|
|
No folder assignment
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Boolean Attributes */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky Footer */}
|
|
<div className="sticky bottom-0 bg-background border-t shadow-[0_-2px_4px_rgba(0,0,0,0.05)] pt-4 z-10">
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleResetFilters}
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button onClick={applyFilters}>
|
|
Apply Filters
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* LLM Selection Modal for Download */}
|
|
<Dialog
|
|
open={downloadLlmModalOpen}
|
|
onOpenChange={setDownloadLlmModalOpen}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Select AI Model for Summary Generation</DialogTitle>
|
|
<DialogDescription>
|
|
Choose which AI model to use for generating persona summaries
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="py-4">
|
|
<RadioGroup
|
|
value={selectedDownloadLlmModel}
|
|
onValueChange={setSelectedDownloadLlmModel}
|
|
className="space-y-3"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="gemini-3-pro-preview" id="download-gemini" />
|
|
<Label htmlFor="download-gemini" className="text-sm font-medium">
|
|
Gemini 3 Pro
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="gpt-4.1" id="download-gpt" />
|
|
<Label htmlFor="download-gpt" className="text-sm font-medium">
|
|
GPT-4.1
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDownloadLlmModalOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleDownloadWithModel}>
|
|
Generate Summary
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<Tabs defaultValue="ai" onValueChange={(value) => setCreationMode(value as 'manual' | 'ai')}>
|
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
|
<TabsTrigger value="ai">AI Recruiter</TabsTrigger>
|
|
<TabsTrigger value="manual">Manual Creation</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="ai">
|
|
{/* Log detailed info about the selected folder */}
|
|
{console.log(`Rendering AIRecruiter with targetFolderId: ${selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : 'null'}`)}
|
|
{console.log(`Current folders:`, folders.map(f => ({ id: f.id, name: f.name })))}
|
|
<AIRecruiter
|
|
targetFolderId={selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : null}
|
|
targetFolderName={selectedFolder !== DEFAULT_FOLDER_ID ? folders.find(f => f.id === selectedFolder)?.name : null}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="manual">
|
|
<UserCreator
|
|
targetFolderId={selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : null}
|
|
targetFolderName={selectedFolder !== DEFAULT_FOLDER_ID ? folders.find(f => f.id === selectedFolder)?.name : null}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SyntheticUsers;
|