feat: optimized participants list with sorting and search

This commit is contained in:
averel10
2026-04-03 08:42:13 +02:00
parent 29ce71e831
commit 49cabe0ecc
2 changed files with 152 additions and 59 deletions

View File

@@ -5,6 +5,7 @@ import { participant } from '@/lib/model/participant';
import { annotation } from '@/lib/model/annotation'; import { annotation } from '@/lib/model/annotation';
import { experiment } from '@/lib/model/experiment'; import { experiment } from '@/lib/model/experiment';
import { dataset_entry } from '@/lib/model/dataset_entry'; import { dataset_entry } from '@/lib/model/dataset_entry';
import { user } from '@/lib/model/auth-schema';
import { and, eq, count } from 'drizzle-orm'; import { and, eq, count } from 'drizzle-orm';
import { requireAdmin } from '@/lib/auth'; import { requireAdmin } from '@/lib/auth';
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring'; import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
@@ -13,6 +14,7 @@ export interface ParticipantListItem {
id: number; id: number;
experimentId: number; experimentId: number;
userId: string; userId: string;
email: string;
completedOnboarding: boolean; completedOnboarding: boolean;
completedCalibration: boolean; completedCalibration: boolean;
annotationCount: number; annotationCount: number;
@@ -53,8 +55,18 @@ export async function getParticipantsList(experimentId: number): Promise<Partici
// Get all participants for this experiment // Get all participants for this experiment
const participants = await db const participants = await db
.select() .select({
id: participant.id,
experimentId: participant.experimentId,
userId: participant.userId,
email: user.email,
calibrationAnswers: participant.calibrationAnswers,
onboardingAnswers: participant.onboardingAnswers,
createdAt: participant.createdAt,
updatedAt: participant.updatedAt,
})
.from(participant) .from(participant)
.leftJoin(user, eq(participant.userId, user.id))
.where(eq(participant.experimentId, experimentId)); .where(eq(participant.experimentId, experimentId));
// Build list with annotation counts // Build list with annotation counts
@@ -81,6 +93,7 @@ export async function getParticipantsList(experimentId: number): Promise<Partici
id: p.id, id: p.id,
experimentId: p.experimentId!, experimentId: p.experimentId!,
userId: p.userId, userId: p.userId,
email: p.email || 'Unknown',
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
completedCalibration, completedCalibration,
annotationCount, annotationCount,

View File

@@ -2,13 +2,60 @@
import Link from 'next/link'; import Link from 'next/link';
import { ParticipantListItem } from '@/app/actions/participants'; import { ParticipantListItem } from '@/app/actions/participants';
import { useState, useMemo } from 'react';
interface ParticipantsListProps { interface ParticipantsListProps {
experimentId: number; experimentId: number;
participants: ParticipantListItem[]; participants: ParticipantListItem[];
} }
type SortField = 'userId' | 'email' | 'createdAt' | 'annotationCount';
type SortOrder = 'asc' | 'desc';
export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) { export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('createdAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const getSortIndicator = (field: SortField) => {
if (sortField !== field) return ' ↑↓';
return sortOrder === 'asc' ? ' ↑' : ' ↓';
};
const filteredAndSortedParticipants = useMemo(() => {
let filtered = participants.filter((p) => {
const query = searchQuery.toLowerCase();
return (
p.userId.toLowerCase().includes(query) ||
p.email.toLowerCase().includes(query)
);
});
filtered.sort((a, b) => {
let aValue: any = a[sortField];
let bValue: any = b[sortField];
if (sortField === 'createdAt') {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
}
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return sortOrder === 'asc' ? comparison : -comparison;
});
return filtered;
}, [participants, searchQuery, sortField, sortOrder]);
if (participants.length === 0) { if (participants.length === 0) {
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
@@ -18,65 +65,98 @@ export default function ParticipantsList({ experimentId, participants }: Partici
} }
return ( return (
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="space-y-4">
<div className="overflow-x-auto"> {/* Search Bar */}
<table className="w-full"> <div className="bg-white rounded-lg shadow p-4">
<thead> <input
<tr className="bg-gray-50 border-b border-gray-200"> type="text"
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">User ID</th> placeholder="Search by User ID or Email..."
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Onboarding</th> value={searchQuery}
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Calibration</th> onChange={(e) => setSearchQuery(e.target.value)}
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Annotations</th> className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
<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> </div>
</tr>
</thead> {/* Table */}
<tbody className="divide-y divide-gray-200"> <div className="bg-white rounded-lg shadow overflow-hidden">
{participants.map((participant) => ( <div className="overflow-x-auto">
<tr key={participant.id} className="hover:bg-gray-50 transition-colors"> <table className="w-full">
<td className="px-6 py-4 text-sm text-gray-900 font-medium">{participant.userId}</td> <thead>
<td className="px-6 py-4 text-sm"> <tr className="bg-gray-50 border-b border-gray-200">
<span <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${ onClick={() => handleSort('userId')}>
participant.completedOnboarding User ID{getSortIndicator('userId')}
? 'bg-green-100 text-green-800' </th>
: 'bg-gray-100 text-gray-800' <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
}`} onClick={() => handleSort('email')}>
> Email{getSortIndicator('email')}
{participant.completedOnboarding ? '✓ Done' : '○ Pending'} </th>
</span> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Onboarding</th>
</td> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Calibration</th>
<td className="px-6 py-4 text-sm"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
<span onClick={() => handleSort('annotationCount')}>
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${ Annotations{getSortIndicator('annotationCount')}
participant.completedCalibration </th>
? 'bg-blue-100 text-blue-800' <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
: 'bg-gray-100 text-gray-800' onClick={() => handleSort('createdAt')}>
}`} Joined{getSortIndicator('createdAt')}
> </th>
{participant.completedCalibration ? '✓ Done' : '○ Pending'} <th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Action</th>
</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> </tr>
))} </thead>
</tbody> <tbody className="divide-y divide-gray-200">
</table> {filteredAndSortedParticipants.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 text-gray-600">{participant.email}</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>
{filteredAndSortedParticipants.length === 0 && (
<div className="px-6 py-8 text-center text-gray-600">
No participants match your search
</div>
)}
</div> </div>
</div> </div>
); );