Extract business logic and UI into reusable pieces: Custom Hooks: - useFocusGroupAutoSave: debounced auto-save with retry logic - useFolderManagement: folder CRUD operations - usePersonaFiltering: filter state and persona filtering - useDiscussionGuideGeneration: guide generation and progress UI Components: - SaveStatusIndicator: auto-save status display - FolderSidebar: folder list and management - PersonaFilterDialog: persona filter modal - CopyGuideDialog: copy guide from other focus groups Tab Components: - SetupTab: form and asset uploader - ReviewTab: discussion guide viewer - ParticipantsTab: persona selection grid Reduces FocusGroupModerator from 2,396 to ~600 lines (75% reduction). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
Search,
|
|
Filter,
|
|
Users,
|
|
Loader2,
|
|
Play,
|
|
} from 'lucide-react';
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import UserCard from "@/components/UserCard";
|
|
import { Persona } from "@/types/persona";
|
|
import { FilterState } from '@/hooks/usePersonaFiltering';
|
|
|
|
interface ParticipantsTabProps {
|
|
personas: any[];
|
|
filteredPersonas: any[];
|
|
selectedParticipants: string[];
|
|
onParticipantToggle: (id: string) => void;
|
|
onViewDetails: (persona: Persona) => void;
|
|
searchTerm: string;
|
|
onSearchChange: (term: string) => void;
|
|
activeFilters: FilterState;
|
|
onOpenFilter: () => void;
|
|
isLoading: boolean;
|
|
// Folder sidebar passed as children or render prop
|
|
folderSidebar: React.ReactNode;
|
|
// Navigation
|
|
onNavigateToReview: () => void;
|
|
onStartFocusGroup: () => void;
|
|
canStart: boolean;
|
|
}
|
|
|
|
export function ParticipantsTab({
|
|
filteredPersonas,
|
|
selectedParticipants,
|
|
onParticipantToggle,
|
|
onViewDetails,
|
|
searchTerm,
|
|
onSearchChange,
|
|
activeFilters,
|
|
onOpenFilter,
|
|
isLoading,
|
|
folderSidebar,
|
|
onNavigateToReview,
|
|
onStartFocusGroup,
|
|
canStart,
|
|
}: ParticipantsTabProps) {
|
|
// Count active filters
|
|
const activeFilterCount = Object.values(activeFilters).reduce((count, arr) => count + arr.length, 0);
|
|
|
|
return (
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
{/* Folder sidebar */}
|
|
{folderSidebar}
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1">
|
|
<Card className="mb-4">
|
|
<CardContent className="p-6">
|
|
<div className="flex flex-col space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-sf text-lg font-medium">Select Participants</h3>
|
|
<div className="flex items-center">
|
|
<Users className="h-5 w-5 mr-2 text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
{selectedParticipants.length} of {filteredPersonas.length} selected
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search and filter bar */}
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<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) => onSearchChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
className="flex items-center gap-2"
|
|
onClick={onOpenFilter}
|
|
>
|
|
<Filter className="h-4 w-4" />
|
|
<span>Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex justify-center items-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
) : filteredPersonas.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
|
|
{filteredPersonas.map((persona) => {
|
|
const personaId = persona._id || persona.id;
|
|
|
|
return (
|
|
<UserCard
|
|
key={personaId}
|
|
user={{
|
|
id: personaId,
|
|
_id: persona._id,
|
|
name: persona.name,
|
|
age: persona.age,
|
|
gender: persona.gender,
|
|
occupation: persona.occupation,
|
|
location: persona.location || 'Unknown',
|
|
techSavviness: persona.techSavviness || 50,
|
|
personality: persona.personality || 'No description available',
|
|
oceanTraits: persona.oceanTraits,
|
|
qualitativeAttributes: persona.qualitativeAttributes,
|
|
topPersonalityTraits: persona.topPersonalityTraits,
|
|
aiSynthesizedBio: persona.aiSynthesizedBio,
|
|
}}
|
|
selected={selectedParticipants.includes(personaId)}
|
|
onSelectionToggle={() => onParticipantToggle(personaId)}
|
|
onViewDetails={onViewDetails}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<p className="text-muted-foreground">No personas available matching your criteria.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-between">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onNavigateToReview}
|
|
>
|
|
Back to Review
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={onStartFocusGroup}
|
|
disabled={!canStart}
|
|
>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
Start Focus Group Session
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ParticipantsTab;
|