feat: added admin participant detail page
This commit is contained in:
@@ -5,41 +5,69 @@ import { experiment_calibration } from '@/lib/model/experiment_calibration';
|
|||||||
import { experiment } from '@/lib/model/experiment';
|
import { experiment } from '@/lib/model/experiment';
|
||||||
import { participant } from '@/lib/model/participant';
|
import { participant } from '@/lib/model/participant';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth, requireAdmin } from '@/lib/auth';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates dialect performance scores based on the participant's calibration answers.
|
* Calculates dialect performance scores based on calibration answers.
|
||||||
* Score = (correctness × confidence) averaged per dialect
|
* Score = (correctness × confidence) averaged per dialect
|
||||||
* Range: -1 to 1, where:
|
* Range: -1 to 1, where:
|
||||||
* 1 = perfect identification with full confidence
|
* 1 = perfect identification with full confidence
|
||||||
* 0 = neutral (correct but unsure, or incorrect and unsure)
|
* 0 = neutral (correct but unsure, or incorrect and unsure)
|
||||||
* -1 = completely wrong with full confidence (penalizes false confidence)
|
* -1 = completely wrong with full confidence (penalizes false confidence)
|
||||||
|
*
|
||||||
|
* @param experimentId - The experiment ID
|
||||||
|
* @param userId - Optional user ID. If provided, requires admin privileges. If not provided, uses current user.
|
||||||
*/
|
*/
|
||||||
export async function getDialectScoresFromCalibration(
|
export async function getDialectScoresFromCalibration(
|
||||||
experimentId: number
|
experimentId: number,
|
||||||
|
userId?: string
|
||||||
): Promise<Record<string, number>> {
|
): Promise<Record<string, number>> {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) throw new Error('Nicht angemeldet');
|
if (!session) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
// Fetch calibration items and participant's answers
|
// Determine which user to fetch scores for
|
||||||
|
let targetUserId = userId;
|
||||||
|
if (targetUserId) {
|
||||||
|
// Admin access required for fetching other user's scores
|
||||||
|
const result = await requireAdmin();
|
||||||
|
if (!result.authenticated || !result.admin) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use current user if no userId provided
|
||||||
|
targetUserId = session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch calibration items
|
||||||
const calibrationItems = await db
|
const calibrationItems = await db
|
||||||
.select()
|
.select()
|
||||||
.from(experiment_calibration)
|
.from(experiment_calibration)
|
||||||
.where(eq(experiment_calibration.experimentId, experimentId));
|
.where(eq(experiment_calibration.experimentId, experimentId));
|
||||||
|
|
||||||
|
// Fetch participant record
|
||||||
const participantRecord = await db
|
const participantRecord = await db
|
||||||
.select()
|
.select()
|
||||||
.from(participant)
|
.from(participant)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(participant.experimentId, experimentId),
|
eq(participant.experimentId, experimentId),
|
||||||
eq(participant.userId, session.user.id)
|
eq(participant.userId, targetUserId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
// Calculate dialect scores based on calibration performance
|
// Calculate dialect scores
|
||||||
|
return calculateDialectScores(calibrationItems, participantRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to calculate dialect scores from calibration items and answers
|
||||||
|
*/
|
||||||
|
function calculateDialectScores(
|
||||||
|
calibrationItems: typeof experiment_calibration.$inferSelect[],
|
||||||
|
participantRecord: (typeof participant.$inferSelect)[]
|
||||||
|
): Record<string, number> {
|
||||||
const dialectScores: Record<string, number> = {};
|
const dialectScores: Record<string, number> = {};
|
||||||
|
|
||||||
if (participantRecord.length > 0 && participantRecord[0].calibrationAnswers) {
|
if (participantRecord.length > 0 && participantRecord[0].calibrationAnswers) {
|
||||||
|
|||||||
231
src/app/actions/participants.ts
Normal file
231
src/app/actions/participants.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import db from '@/lib/db';
|
||||||
|
import { participant } from '@/lib/model/participant';
|
||||||
|
import { annotation } from '@/lib/model/annotation';
|
||||||
|
import { experiment } from '@/lib/model/experiment';
|
||||||
|
import { dataset_entry } from '@/lib/model/dataset_entry';
|
||||||
|
import { and, eq, count } from 'drizzle-orm';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
|
||||||
|
|
||||||
|
export interface ParticipantListItem {
|
||||||
|
id: number;
|
||||||
|
experimentId: number;
|
||||||
|
userId: string;
|
||||||
|
completedOnboarding: boolean;
|
||||||
|
completedCalibration: boolean;
|
||||||
|
annotationCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantDetail extends ParticipantListItem {
|
||||||
|
onboardingAnswers: any;
|
||||||
|
calibrationAnswers: any;
|
||||||
|
annotations: Array<{
|
||||||
|
id: number;
|
||||||
|
datasetEntryId: number;
|
||||||
|
externalId: string;
|
||||||
|
fileName: string;
|
||||||
|
datasetId: number;
|
||||||
|
rating: number;
|
||||||
|
dialectLabel: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all participants for an experiment as a list
|
||||||
|
*/
|
||||||
|
export async function getParticipantsList(experimentId: number): Promise<ParticipantListItem[]> {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
if (!result.authenticated || !result.admin) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify experiment exists
|
||||||
|
const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1);
|
||||||
|
if (!exp || exp.length === 0) {
|
||||||
|
throw new Error('Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all participants for this experiment
|
||||||
|
const participants = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(eq(participant.experimentId, experimentId));
|
||||||
|
|
||||||
|
// Build list with annotation counts
|
||||||
|
const participantsList: ParticipantListItem[] = [];
|
||||||
|
|
||||||
|
for (const p of participants) {
|
||||||
|
// Count annotations for this participant
|
||||||
|
const annotationCounts = await db
|
||||||
|
.select({ count: count().as('count') })
|
||||||
|
.from(annotation)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(annotation.experimentId, experimentId),
|
||||||
|
eq(annotation.userId, p.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const annotationCount = annotationCounts[0]?.count || 0;
|
||||||
|
|
||||||
|
// Use the proper calibration validation function
|
||||||
|
const completedCalibration = await isParticipantCalibrationDone(experimentId, p);
|
||||||
|
|
||||||
|
participantsList.push({
|
||||||
|
id: p.id,
|
||||||
|
experimentId: p.experimentId!,
|
||||||
|
userId: p.userId,
|
||||||
|
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
||||||
|
completedCalibration,
|
||||||
|
annotationCount,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by descending creation date
|
||||||
|
participantsList.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
return participantsList;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching participants list:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a specific participant
|
||||||
|
*/
|
||||||
|
export async function getParticipantDetail(
|
||||||
|
experimentId: number,
|
||||||
|
userId: string
|
||||||
|
): Promise<ParticipantDetail> {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
if (!result.authenticated || !result.admin) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify experiment exists
|
||||||
|
const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1);
|
||||||
|
if (!exp || exp.length === 0) {
|
||||||
|
throw new Error('Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get participant
|
||||||
|
const participants = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participant.experimentId, experimentId),
|
||||||
|
eq(participant.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!participants || participants.length === 0) {
|
||||||
|
throw new Error('Participant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = participants[0];
|
||||||
|
|
||||||
|
// Get all annotations for this participant
|
||||||
|
const participantAnnotations = await db
|
||||||
|
.select({
|
||||||
|
id: annotation.id,
|
||||||
|
datasetEntryId: annotation.datasetEntryId,
|
||||||
|
externalId: dataset_entry.externalId,
|
||||||
|
fileName: dataset_entry.fileName,
|
||||||
|
datasetId: dataset_entry.datasetId,
|
||||||
|
rating: annotation.rating,
|
||||||
|
dialectLabel: annotation.dialectLabel,
|
||||||
|
createdAt: annotation.createdAt,
|
||||||
|
})
|
||||||
|
.from(annotation)
|
||||||
|
.leftJoin(dataset_entry, eq(annotation.datasetEntryId, dataset_entry.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(annotation.experimentId, experimentId),
|
||||||
|
eq(annotation.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the proper calibration validation function
|
||||||
|
const completedCalibration = await isParticipantCalibrationDone(experimentId, p);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
experimentId: p.experimentId!,
|
||||||
|
userId: p.userId,
|
||||||
|
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
||||||
|
completedCalibration,
|
||||||
|
onboardingAnswers: p.onboardingAnswers || {},
|
||||||
|
calibrationAnswers: p.calibrationAnswers || {},
|
||||||
|
annotationCount: participantAnnotations.length,
|
||||||
|
annotations: participantAnnotations.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
datasetEntryId: a.datasetEntryId,
|
||||||
|
externalId: a.externalId || 'Unknown', fileName: a.fileName || 'Unknown',
|
||||||
|
datasetId: a.datasetId || 0, rating: a.rating,
|
||||||
|
dialectLabel: a.dialectLabel,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
})),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching participant detail:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get calibration scores for a participant
|
||||||
|
*/
|
||||||
|
export async function getParticipantCalibrationScores(
|
||||||
|
experimentId: number,
|
||||||
|
userId: string
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
if (!result.authenticated || !result.admin) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify experiment exists
|
||||||
|
const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1);
|
||||||
|
if (!exp || exp.length === 0) {
|
||||||
|
throw new Error('Experiment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify participant exists
|
||||||
|
const participants = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participant.experimentId, experimentId),
|
||||||
|
eq(participant.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!participants || participants.length === 0) {
|
||||||
|
throw new Error('Participant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get calibration scores using the existing server action
|
||||||
|
const scores = await getDialectScoresFromCalibration(experimentId, userId);
|
||||||
|
return scores;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching participant calibration scores:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,13 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) {
|
|||||||
|
|
||||||
<EditableExperimentHeader experiment={exp} />
|
<EditableExperimentHeader experiment={exp} />
|
||||||
|
|
||||||
<div className="flex gap-3 mb-6">
|
<div className="flex gap-3 mb-6 flex-wrap">
|
||||||
|
<Link
|
||||||
|
href={`/admin/experiments/${experimentId}/participants`}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
View Participants
|
||||||
|
</Link>
|
||||||
<DeleteExperimentButton experimentId={experimentId} experimentName={exp.name} />
|
<DeleteExperimentButton experimentId={experimentId} experimentName={exp.name} />
|
||||||
<ClearExperimentDataButton experimentId={experimentId} experimentName={exp.name} />
|
<ClearExperimentDataButton experimentId={experimentId} experimentName={exp.name} />
|
||||||
<UploadCalibrationModal experimentId={experimentId} />
|
<UploadCalibrationModal experimentId={experimentId} />
|
||||||
|
|||||||
101
src/app/admin/experiments/[id]/participants/[userId]/page.tsx
Normal file
101
src/app/admin/experiments/[id]/participants/[userId]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { getParticipantDetail } from '@/app/actions/participants';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import db from '@/lib/db';
|
||||||
|
import { experiment } from '@/lib/model/experiment';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import ParticipantDetailView from '@/components/ParticipantDetailView';
|
||||||
|
|
||||||
|
interface ParticipantDetailPageProps {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ParticipantPage({ params }: ParticipantDetailPageProps) {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
|
||||||
|
if (!result.authenticated) {
|
||||||
|
redirect("/user/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.admin) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, userId } = await params;
|
||||||
|
const experimentId = parseInt(id, 10);
|
||||||
|
|
||||||
|
// Verify experiment exists
|
||||||
|
const experiments = await db
|
||||||
|
.select()
|
||||||
|
.from(experiment)
|
||||||
|
.where(eq(experiment.id, experimentId));
|
||||||
|
|
||||||
|
if (experiments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Link
|
||||||
|
href="/admin/experiments"
|
||||||
|
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Experiments
|
||||||
|
</Link>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<p className="text-gray-600">Experiment not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = experiments[0];
|
||||||
|
|
||||||
|
// Get participant details
|
||||||
|
let participant;
|
||||||
|
try {
|
||||||
|
participant = await getParticipantDetail(experimentId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Link
|
||||||
|
href={`/admin/experiments/${experimentId}/participants`}
|
||||||
|
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Participants
|
||||||
|
</Link>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<p className="text-gray-600">Participant not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/admin/experiments/${experimentId}/participants`}
|
||||||
|
className="text-blue-500 hover:text-blue-600 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Participants
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{exp.name}</p>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Participant Data</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ParticipantDetailView participant={participant} experimentId={experimentId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/admin/experiments/[id]/participants/page.tsx
Normal file
83
src/app/admin/experiments/[id]/participants/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { getParticipantsList } from '@/app/actions/participants';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import db from '@/lib/db';
|
||||||
|
import { experiment } from '@/lib/model/experiment';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import ParticipantsList from '@/components/ParticipantsList';
|
||||||
|
|
||||||
|
interface ParticipantsPageProps {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ParticipantsPage({ params }: ParticipantsPageProps) {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
|
||||||
|
if (!result.authenticated) {
|
||||||
|
redirect("/user/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.admin) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const experimentId = parseInt(id, 10);
|
||||||
|
|
||||||
|
// Verify experiment exists
|
||||||
|
const experiments = await db
|
||||||
|
.select()
|
||||||
|
.from(experiment)
|
||||||
|
.where(eq(experiment.id, experimentId));
|
||||||
|
|
||||||
|
if (experiments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Link
|
||||||
|
href="/admin/experiments"
|
||||||
|
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Experiments
|
||||||
|
</Link>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<p className="text-gray-600">Experiment not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = experiments[0];
|
||||||
|
|
||||||
|
// Get participants
|
||||||
|
const participants = await getParticipantsList(experimentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/admin/experiments/${experimentId}`}
|
||||||
|
className="text-blue-500 hover:text-blue-600 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Experiment
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Participants</h1>
|
||||||
|
<p className="text-gray-600 mt-1">{exp.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{participants.length}</div>
|
||||||
|
<div className="text-sm text-gray-600">Total Participants</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ParticipantsList experimentId={experimentId} participants={participants} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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