feat: added admin participant detail page
This commit is contained in:
183
src/components/ParticipantDetailView.tsx
Normal file
183
src/components/ParticipantDetailView.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { ParticipantDetail, getParticipantCalibrationScores } from '@/app/actions/participants';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CalibrationScoresDisplay from './CalibrationScoresDisplay';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
interface ParticipantDetailViewProps {
|
||||
participant: ParticipantDetail;
|
||||
experimentId: number;
|
||||
}
|
||||
|
||||
interface CalibrationScores {
|
||||
[dialectLabel: string]: number;
|
||||
}
|
||||
|
||||
export default function ParticipantDetailView({ participant, experimentId }: ParticipantDetailViewProps) {
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<(typeof participant.annotations)[0] | null>(
|
||||
participant.annotations.length > 0 ? participant.annotations[0] : null
|
||||
);
|
||||
const [calibrationScores, setCalibrationScores] = useState<CalibrationScores | null>(null);
|
||||
const [scoresLoading, setScoresLoading] = useState(false);
|
||||
const [scoresError, setScoresError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (participant.completedCalibration) {
|
||||
const fetchScores = async () => {
|
||||
setScoresLoading(true);
|
||||
setScoresError(null);
|
||||
try {
|
||||
const scores = await getParticipantCalibrationScores(experimentId, participant.userId);
|
||||
setCalibrationScores(scores);
|
||||
} catch (err) {
|
||||
setScoresError(err instanceof Error ? err.message : 'Failed to load calibration scores');
|
||||
} finally {
|
||||
setScoresLoading(false);
|
||||
}
|
||||
};
|
||||
fetchScores();
|
||||
}
|
||||
}, [experimentId, participant.userId, participant.completedCalibration]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{participant.userId}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 rounded p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Status</div>
|
||||
<div className="text-lg font-semibold text-gray-900">Joined</div>
|
||||
<div className="text-xs text-gray-600 mt-2">
|
||||
{new Date(participant.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Onboarding</div>
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{participant.completedOnboarding ? '✓ Completed' : '○ Pending'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Calibration</div>
|
||||
<div className="text-lg font-semibold text-blue-600">
|
||||
{participant.completedCalibration ? '✓ Completed' : '○ Pending'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Annotations</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{participant.annotationCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Data */}
|
||||
{participant.completedOnboarding && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Onboarding Data</h3>
|
||||
<div className="bg-gray-50 rounded p-4 font-mono text-sm whitespace-pre-wrap break-words">
|
||||
<code>{JSON.stringify(participant.onboardingAnswers, null, 2)}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calibration Data */}
|
||||
{participant.completedCalibration && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Calibration Data</h3>
|
||||
<div className="bg-gray-50 rounded p-4 font-mono text-sm whitespace-pre-wrap break-words">
|
||||
<code>{JSON.stringify(participant.calibrationAnswers, null, 2)}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calibration Scores */}
|
||||
{participant.completedCalibration && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Calibration Scores</h3>
|
||||
{scoresLoading && <div className="text-gray-600">Loading scores...</div>}
|
||||
{scoresError && <div className="text-red-600">Error: {scoresError}</div>}
|
||||
{calibrationScores && Object.keys(calibrationScores).length > 0 ? (
|
||||
<CalibrationScoresDisplay dialectScores={calibrationScores} />
|
||||
) : (
|
||||
<div className="text-gray-600">No calibration scores available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotations */}
|
||||
{participant.annotationCount > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Annotations List */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Annotations ({participant.annotationCount})</h3>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{participant.annotations.map((annotation) => (
|
||||
<button
|
||||
key={annotation.id}
|
||||
onClick={() => setSelectedAnnotation(annotation)}
|
||||
className={`w-full text-left p-3 rounded border-2 transition-colors ${
|
||||
selectedAnnotation?.id === annotation.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{annotation.externalId}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Rating: <span className="font-semibold">{annotation.rating}</span> | Dialect:{' '}
|
||||
<span className="font-semibold">{annotation.dialectLabel}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date(annotation.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Annotation Details */}
|
||||
{selectedAnnotation && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-lg font-bold text-gray-900 mb-4">Details</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">External ID</div>
|
||||
<div className="font-medium text-gray-900 break-all">{selectedAnnotation.externalId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Rating</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{selectedAnnotation.rating}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Dialect</div>
|
||||
<div className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
{selectedAnnotation.dialectLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Timestamp</div>
|
||||
<div className="text-sm text-gray-900">{new Date(selectedAnnotation.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Audio</div>
|
||||
<AudioPlayer
|
||||
fileName={selectedAnnotation.fileName}
|
||||
datasetId={selectedAnnotation.datasetId}
|
||||
externalId={selectedAnnotation.externalId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{participant.annotationCount === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">No annotations yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/ParticipantsList.tsx
Normal file
83
src/components/ParticipantsList.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ParticipantListItem } from '@/app/actions/participants';
|
||||
|
||||
interface ParticipantsListProps {
|
||||
experimentId: number;
|
||||
participants: ParticipantListItem[];
|
||||
}
|
||||
|
||||
export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) {
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">No participants yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">User ID</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Onboarding</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Calibration</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Annotations</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Joined</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{participants.map((participant) => (
|
||||
<tr key={participant.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-900 font-medium">{participant.userId}</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
||||
participant.completedOnboarding
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{participant.completedOnboarding ? '✓ Done' : '○ Pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
||||
participant.completedCalibration
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{participant.completedCalibration ? '✓ Done' : '○ Pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">
|
||||
<span className="inline-block px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-xs font-medium">
|
||||
{participant.annotationCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(participant.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<Link
|
||||
href={`/admin/experiments/${experimentId}/participants/${participant.userId}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user